You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tinkerpop.apache.org by co...@apache.org on 2026/06/12 18:13:17 UTC

(tinkerpop) branch master updated: Implement Provider Defined Types (PDT) (#3433)

This is an automated email from the ASF dual-hosted git repository.

Cole-Greer pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/tinkerpop.git


The following commit(s) were added to refs/heads/master by this push:
     new 6c34b26291 Implement Provider Defined Types (PDT) (#3433)
6c34b26291 is described below

commit 6c34b26291a2a781442eed15ffa758061a799d67
Author: Cole Greer <11...@users.noreply.github.com>
AuthorDate: Fri Jun 12 11:13:11 2026 -0700

    Implement Provider Defined Types (PDT) (#3433)
    
    Add CompositePDT (0xF0) support enabling graph providers to define custom types that serialize/deserialize seamlessly across all GLVs without driver-side configuration. Replaces the TP3 CustomTypeSerializer mechanism.
    
    Core (gremlin-core):
    
        Add @ProviderDefined annotation and immutable ProviderDefinedType POJO
        Add ProviderDefinedTypeSerializer for GraphBinary with wire format: fully-qualified type string + fully-qualified fields map
        Add PdtGraphSONSerializersV4 with g:CompositePdt type tag
        Add ProviderDefinedTypeAdapter and ProviderDefinedTypeRegistry with ServiceLoader discovery, recursive hydration, and graceful degradation on adapter failure
        Integrate auto-hydration into GraphBinaryReader and GraphSONMapper
        Add GraphBinaryWriter auto-conversion for @ProviderDefined objects
        Cache reflection metadata per class for performance
        Support inherited fields via superclass walking
        Remove legacy CUSTOM(0x00) type mechanism entirely
    
    Gremlin-lang:
    
        Add PDT("name", ["key":value]) literal to ANTLR grammar
        Server-side parser constructs ProviderDefinedType from PDT literals
        All GLV translators emit PDT literal syntax
        Registry-based and annotation-based auto-dehydration in translators
        All TranslateVisitors handle PDT for cross-language translation
    
    GLV support (all languages):
    
        Python: ProviderDefinedType, serializer, registry with @provider_defined decorator, entry_points auto-discovery, registry wired through Client/DriverRemoteConnection
        JavaScript: ProviderDefinedType, CompositePDTSerializer, registry with explicit function pair registration, client options wiring
        Go: ProviderDefinedType struct, serializer/deserializer, PDTRegistry with struct tags, RegisterFuncs, PDTProvider interface, client wiring
        .NET: ProviderDefinedType, CompositePDTSerializer, registry with IProviderDefinedTypeAdapter, [ProviderDefined] attribute with IncludedFields/ExcludedFields, assembly scanning, IMessageSerializer .SetPdtRegistry() interface method, client/connection wiring
    
    Server and testing:
    
        PDT flows end-to-end through gremlin-server with TinkerGraph storing original objects and conversion at serialization boundary
        Test-jar with Point, Address, Person test types for Docker server
        Integration tests in all GLVs using gremlin-lang PDT literals
        Traversal API tests covering raw PDT, registry hydration, and annotation-based auto-dehydration round-trips
    
    Assisted-by: Kiro:claude-opus-4.8
---
 .dockerignore                                      |   1 +
 CHANGELOG.asciidoc                                 |   1 +
 docker/gremlin-test-server/Dockerfile              |   1 +
 docs/src/dev/provider/index.asciidoc               | 219 +++++++++++
 docs/src/reference/gremlin-variants.asciidoc       | 297 +++++++++++++++
 docs/src/upgrade/release-4.x.x.asciidoc            |  31 ++
 .../grammar/DefaultGremlinBaseVisitor.java         |   4 +
 .../language/grammar/GenericLiteralVisitor.java    |  17 +
 .../translator/AnonymizedTranslatorVisitor.java    |   6 +
 .../translator/DotNetTranslateVisitor.java         |  10 +
 .../language/translator/GoTranslateVisitor.java    |  10 +
 .../translator/GroovyTranslateVisitor.java         |  10 +
 .../language/translator/JavaTranslateVisitor.java  |  10 +
 .../translator/JavascriptTranslateVisitor.java     |  10 +
 .../translator/PythonTranslateVisitor.java         |  10 +
 .../language/translator/TranslateVisitor.java      |   6 +
 .../gremlin/process/remote/RemoteConnection.java   |   9 +
 .../gremlin/process/traversal/GremlinLang.java     |  45 +++
 .../traversal/dsl/graph/GraphTraversalSource.java  |   3 +
 .../gremlin/structure/io/binary/DataType.java      |   3 +-
 .../gremlin/structure/io/binary/GraphBinaryIo.java |  60 ---
 .../structure/io/binary/GraphBinaryReader.java     |  21 +-
 .../structure/io/binary/GraphBinaryWriter.java     |  20 +-
 .../io/binary/TypeSerializerRegistry.java          |  94 +----
 .../types/ProviderDefinedTypeSerializer.java       |  56 +++
 .../structure/io/graphson/GraphSONMapper.java      |  17 +
 .../structure/io/graphson/GraphSONModule.java      |  12 +
 .../io/graphson/PdtGraphSONSerializersV4.java      | 116 ++++++
 .../ProviderDefined.java}                          |  23 +-
 .../structure/io/pdt/ProviderDefinedType.java      | 186 +++++++++
 .../ProviderDefinedTypeAdapter.java}               |  17 +-
 .../io/pdt/ProviderDefinedTypeRegistry.java        | 224 +++++++++++
 .../grammar/GeneralLiteralVisitorTest.java         |  41 ++
 .../language/translator/GremlinTranslatorTest.java |   9 +
 .../gremlin/process/traversal/GremlinLangTest.java |  91 +++++
 .../io/graphson/PdtGraphSONSerializersV4Test.java  | 230 ++++++++++++
 .../io/pdt/ProviderDefinedTypeRegistryTest.java    | 416 +++++++++++++++++++++
 .../structure/io/pdt/ProviderDefinedTypeTest.java  | 227 +++++++++++
 .../src/Gremlin.Net/Driver/GremlinClient.cs        |  23 +-
 .../src/Gremlin.Net/Driver/IMessageSerializer.cs   |   8 +
 .../Driver/Remote/DriverRemoteConnection.cs        |  18 +-
 .../Process/Remote/IRemoteConnection.cs            |   6 +
 .../Process/Traversal/AnonymousTraversalSource.cs  |   2 +-
 .../Gremlin.Net/Process/Traversal/GremlinLang.cs   |  91 ++++-
 .../Structure/IO/GraphBinary4/DataType.cs          |   2 +-
 .../GraphBinary4/GraphBinary4MessageSerializer.cs  |  21 +-
 .../Structure/IO/GraphBinary4/GraphBinaryReader.cs |  19 +-
 .../IO/GraphBinary4/TypeSerializerRegistry.cs      |   2 +
 .../GraphBinary4/Types/CompositePDTSerializer.cs   |  78 ++++
 .../Structure/IProviderDefinedTypeAdapter.cs       |  49 +++
 .../Structure/ProviderDefinedAttribute.cs          | 107 ++++++
 .../Gremlin.Net/Structure/ProviderDefinedType.cs   |  70 ++++
 .../Structure/ProviderDefinedTypeRegistry.cs       | 159 ++++++++
 .../Driver/DriverRemoteConnectionTests.cs          | 100 +++++
 .../Driver/GremlinClientTests.cs                   |  68 ++++
 .../Process/Traversal/GremlinLangTests.cs          | 143 +++++++
 .../IO/GraphBinary4/ProviderDefinedTypeTests.cs    | 137 +++++++
 .../Structure/ProviderDefinedAttributeTests.cs     | 216 +++++++++++
 .../Structure/ProviderDefinedTypeRegistryTests.cs  | 242 ++++++++++++
 .../src/main/java/examples/Connections.java        |   7 +-
 .../driver/remote/DriverRemoteConnection.java      |  17 +
 gremlin-examples/gremlin-java/Connections.java     |   7 +-
 gremlin-go/driver/client.go                        |   4 +
 gremlin-go/driver/client_test.go                   |  95 +++++
 gremlin-go/driver/connection.go                    |   8 +-
 gremlin-go/driver/driverRemoteConnection.go        |   4 +
 gremlin-go/driver/error_codes.go                   |   1 -
 gremlin-go/driver/graphBinaryDeserializer.go       |  51 ++-
 gremlin-go/driver/graphBinarySerializer.go         |   4 +-
 gremlin-go/driver/graphBinarySerializer_test.go    | 101 +++++
 gremlin-go/driver/graphTraversalSource.go          |   7 +
 gremlin-go/driver/gremlinlang.go                   |  44 ++-
 gremlin-go/driver/gremlinlang_test.go              | 117 ++++++
 gremlin-go/driver/pdtRegistry.go                   | 105 ++++++
 gremlin-go/driver/pdtRegistry_test.go              | 116 ++++++
 gremlin-go/driver/providerDefinedType.go           |  51 +++
 gremlin-go/driver/providerDefinedType_test.go      |  36 ++
 gremlin-go/driver/serializer.go                    |  26 +-
 gremlin-go/driver/traversal_test.go                |  70 ++++
 .../gremlin-javascript/lib/driver/connection.ts    |   9 +-
 .../lib/driver/remote-connection.ts                |   2 +-
 gremlin-js/gremlin-javascript/lib/index.ts         |   3 +
 .../lib/process/anonymous-traversal.ts             |   6 +-
 .../gremlin-javascript/lib/process/gremlin-lang.ts |  21 +-
 .../lib/structure/ProviderDefinedTypeRegistry.ts   |  81 ++++
 .../gremlin-javascript/lib/structure/graph.ts      |  18 +
 .../lib/structure/io/binary/GraphBinary.js         |  62 +--
 .../structure/io/binary/internals/AnySerializer.js |   1 +
 .../io/binary/internals/CompositePDTSerializer.js  |  85 +++++
 .../io/binary/internals/GraphBinaryReader.js       |   3 +
 .../test/integration/client-tests.js               |  69 +++-
 .../test/integration/traversal-test.js             |  78 +++-
 .../graphbinary/CompositePDTSerializer-test.js     | 150 ++++++++
 .../test/unit/graphbinary/precise-mode-test.js     |   2 +-
 .../test/unit/gremlin-lang-test.js                 |  39 +-
 .../test/unit/pdt-registry-test.js                 | 174 +++++++++
 gremlin-language/src/main/antlr4/Gremlin.g4        |   7 +
 .../main/python/gremlin_python/driver/client.py    |   6 +-
 .../driver/driver_remote_connection.py             |   4 +-
 .../python/gremlin_python/driver/serializer.py     |  11 +-
 .../gremlin_python/process/graph_traversal.py      |   2 +
 .../python/gremlin_python/process/traversal.py     |  28 +-
 .../main/python/gremlin_python/structure/graph.py  | 124 ++++++
 .../gremlin_python/structure/io/graphbinaryV4.py   |  60 ++-
 .../src/main/python/tests/integration/conftest.py  |  30 ++
 .../python/tests/integration/driver/test_client.py |  58 +++
 .../driver/test_driver_remote_connection.py        |  33 +-
 .../python/tests/unit/process/test_gremlin_lang.py |  76 ++++
 .../tests/unit/structure/io/test_graphbinaryV4.py  |  24 +-
 .../structure/io/test_provider_defined_type.py     | 244 ++++++++++++
 gremlin-server/pom.xml                             |  16 +
 .../gremlin/server/GremlinDriverIntegrateTest.java | 160 ++++++++
 .../GremlinServerSerializationIntegrateTest.java   |  86 +++++
 .../tinkerpop/gremlin/server/pdt/Address.java      |  24 +-
 .../tinkerpop/gremlin/server/pdt/Person.java       |  32 +-
 .../apache/tinkerpop/gremlin/server/pdt/Point.java |  21 +-
 .../util/ser/GraphBinaryMessageSerializerV4.java   |  69 +---
 .../binary/GraphBinaryPdtSpiAutoWiringTest.java    | 134 +++++++
 .../util/ser/binary/GraphBinaryWriterPdtTest.java  |  94 +++++
 .../gremlin/util/ser/binary/TestPointAdapter.java  |  63 ++++
 .../ser/binary/TypeSerializerRegistryTest.java     |  10 +-
 .../types/ProviderDefinedTypeSerializerTest.java   | 232 ++++++++++++
 .../types/sample/SamplePersonSerializer.java       | 117 ------
 .../types/sample/SamplePersonSerializerTest.java   | 126 -------
 ...lin.structure.io.pdt.ProviderDefinedTypeAdapter |   1 +
 125 files changed, 7050 insertions(+), 640 deletions(-)

diff --git a/.dockerignore b/.dockerignore
index 4e566f8f81..bdfad38b34 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,6 +1,7 @@
 **/*.log
 **/target
 !gremlin-server/target/apache-tinkerpop-gremlin-server-*
+!gremlin-server/target/gremlin-server-*-tests.jar
 !gremlin-console/target/apache-tinkerpop-gremlin-console-*
 !gremlin-tools/gremlin-socket-server/target/gremlin-socket-server-*
 !gremlin-tools/gremlin-socket-server/target/libs
diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc
index 837007596f..4971f79217 100644
--- a/CHANGELOG.asciidoc
+++ b/CHANGELOG.asciidoc
@@ -29,6 +29,7 @@ image::https://raw.githubusercontent.com/apache/tinkerpop/master/docs/static/ima
 * Fixed `ByteBuf` leak in `GraphBinaryMessageSerializerV4` when serialization throws an `IOException`.
 * Added typed numeric wrappers and `preciseNumbers` connection option to `gremlin-javascript` for explicit control over numeric type serialization and deserialization.
 * Added `NextN(n)` to `Traversal` in `gremlin-go` for batched result iteration, providing API parity with `next(n)` in the Java, Python, and .NET GLVs, and updated the Go translators in `gremlin-core` and `gremlin-javascript` to emit `NextN(n)` for the batched form.
+* Added Provider Defined Types (PDT) support — graph providers can define custom types via `@ProviderDefined` annotation that serialize/deserialize seamlessly across all GLVs without driver-side configuration. Replaces TP3 custom type mechanism.
 * Added Gremlator, a single page web application, that translates Gremlin into various programming languages like Javascript and Python.
 * Added explicit transaction support to all non-Java GLVs (gremlin-python, gremlin-go, gremlin-javascript, gremlin-dotnet).
 * Changed default transaction close behavior from commit to rollback across all GLVs to align with embedded graph defaults.
diff --git a/docker/gremlin-test-server/Dockerfile b/docker/gremlin-test-server/Dockerfile
index 8ac853c1d1..3740499dcc 100644
--- a/docker/gremlin-test-server/Dockerfile
+++ b/docker/gremlin-test-server/Dockerfile
@@ -24,6 +24,7 @@ USER root
 RUN mkdir -p /opt
 WORKDIR /opt
 COPY gremlin-server/src/test /opt/test/
+COPY gremlin-server/target/gremlin-server-*-tests.jar /opt/gremlin-server/lib/
 COPY docker/gremlin-server/docker-entrypoint.sh docker/gremlin-server/*.yaml docker/gremlin-server/*.conf /opt/
 RUN chmod 755 /opt/docker-entrypoint.sh
 
diff --git a/docs/src/dev/provider/index.asciidoc b/docs/src/dev/provider/index.asciidoc
index f1c1bc6446..45c4cd8a91 100644
--- a/docs/src/dev/provider/index.asciidoc
+++ b/docs/src/dev/provider/index.asciidoc
@@ -1358,6 +1358,225 @@ can be used as a reference on how these files can be used and its
 link:https://github.com/apache/tinkerpop/blob/x.y.z/gremlin-util/src/test/java/org/apache/tinkerpop/gremlin/structure/io/Model.java[model]
 shows the Java representation of those files.
 
+[[provider-defined-types]]
+=== Provider Defined Types (PDT)
+
+Provider Defined Types allow graph providers to expose custom types that drivers can serialize and deserialize without
+manual configuration on the client side. A provider annotates a class (or registers an adapter for a class it doesn't
+own), and the type flows through the wire protocol automatically. Clients receive PDT values as structured objects they
+can use directly or hydrate into language-native types.
+
+==== Basic Usage
+
+Annotate a class with `@ProviderDefined` from the `org.apache.tinkerpop.gremlin.structure.io.pdt` package:
+
+[source,java]
+----
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefined;
+
+@ProviderDefined(name = "mygraph:Point")
+public class Point {
+    public double x;
+    public double y;
+
+    public Point(double x, double y) {
+        this.x = x;
+        this.y = y;
+    }
+}
+----
+
+The `name` attribute is a unique identifier for the type. It must not be null or empty — all GLVs reject an
+empty name when a type is defined or a `ProviderDefinedType` is constructed. It is strongly recommended to namespace
+type names using your graph's identifier as a prefix (e.g. `"mygraph:Point"`). This avoids collisions when clients
+interact with multiple providers and makes the origin of a type immediately clear. By default, all fields are included.
+Use `includedFields` or `excludedFields` to control which fields are serialized:
+
+[source,java]
+----
+@ProviderDefined(name = "mygraph:Point", includedFields = {"x", "y"})
+public class Point { ... }
+
+// or exclude specific fields
+@ProviderDefined(name = "mygraph:Person", excludedFields = {"internalId"})
+public class Person { ... }
+----
+
+NOTE: For annotation-based round-trip hydration (see <<round-trip-support>>), an annotated class must expose a no-arg
+constructor and the mapped fields must be directly settable (e.g. public fields). Classes that cannot meet these
+requirements — for example those with immutable `final` fields or no default constructor — should instead use a
+`ProviderDefinedTypeAdapter` (see <<adapter-for-types-you-don-t-own>>), which gives full control over construction.
+
+The serialized field set becomes a map whose keys are always strings. In statically typed GLVs (Java, .NET,
+Go, and TypeScript) this is enforced by the type system; in Python the keys are validated at runtime and a
+`TypeError` is raised for any non-string key.
+
+==== Nested Types
+
+PDT supports nested custom types. Each nested type must also be annotated:
+
+[source,java]
+----
+@ProviderDefined(name = "mygraph:Address")
+public class Address {
+    public String street;
+    public String city;
+}
+
+@ProviderDefined(name = "mygraph:Person")
+public class Person {
+    public String name;
+    public Address address;
+}
+----
+
+When serialized, the `address` field is itself encoded as a PDT value.
+
+[[adapter-for-types-you-don-t-own]]
+==== Adapter for Types You Don't Own
+
+For classes you cannot annotate (e.g. `java.awt.Color`), implement `ProviderDefinedTypeAdapter<T>`:
+
+[source,java]
+----
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeAdapter;
+
+public class ColorAdapter implements ProviderDefinedTypeAdapter<java.awt.Color> {
+
+    @Override
+    public String typeName() { return "mygraph:Color"; }
+
+    @Override
+    public Class<java.awt.Color> targetClass() { return java.awt.Color.class; }
+
+    @Override
+    public Map<String, Object> toFields(java.awt.Color color) {
+        return Map.of("r", color.getRed(), "g", color.getGreen(),
+                      "b", color.getBlue(), "a", color.getAlpha());
+    }
+
+    @Override
+    public java.awt.Color fromFields(Map<String, Object> fields) {
+        return new java.awt.Color((int) fields.get("r"), (int) fields.get("g"),
+                                  (int) fields.get("b"), (int) fields.get("a"));
+    }
+}
+----
+
+[[round-trip-support]]
+==== Round-Trip Support (Dehydration and Hydration)
+
+There is an important distinction between *dehydration* (serializing a type for sending) and *hydration* (deserializing
+a received PDT back into a language-native type).
+
+*Dehydration* is handled automatically for `@ProviderDefined`-annotated classes and adapter-registered types. When a
+user passes an annotated object into a Gremlin traversal or script, TinkerPop converts it to a PDT on the wire
+without any extra configuration.
+
+*Hydration* — reconstructing an incoming PDT back into the original typed object — requires the driver to know which
+class corresponds to a given PDT name. Without this mapping, the driver will return a generic `ProviderDefinedType`
+object. To enable automatic round-trip hydration, providers must expose a pre-configured `ProviderDefinedTypeRegistry`
+to users. How that registry is populated differs by language:
+
+===== Java
+
+Register annotated classes explicitly with the registry. `register(Class<?>...)` inspects the `@ProviderDefined`
+annotation to derive the type name and field mapping automatically:
+
+[source,java]
+----
+ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.create();
+registry.register(Point.class, Address.class, Person.class);
+----
+
+Adapter types (for classes you don't own) are discovered automatically via `ServiceLoader` when using
+`ProviderDefinedTypeRegistry.create()`. Register them by adding a file at:
+
+----
+META-INF/services/org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeAdapter
+----
+
+with the fully qualified class name of each adapter:
+
+----
+com.example.graph.ColorAdapter
+----
+
+NOTE: Annotation-based hydration reflectively constructs and populates the annotated class
+(`Constructor.setAccessible(true)` and `Field.set()` in `AnnotatedTypeAdapter`). Under JPMS, the provider's module
+must therefore `opens` its PDT package to `org.apache.tinkerpop.gremlin.core` (e.g.
+`opens com.example.graph.types to org.apache.tinkerpop.gremlin.core;` in `module-info.java`, or the equivalent
+`--add-opens` JVM flag), otherwise the JVM throws `InaccessibleObjectException` at hydration time. Providers using a
+`ProviderDefinedTypeAdapter` with explicit `fromFields` logic do not rely on reflection and are unaffected.
+
+===== Python
+
+Hydration is fully automatic for `@provider_defined`-decorated classes. The decorator registers the class at
+definition time (import time), so any annotated type round-trips without any additional setup.
+
+===== .NET
+
+`[ProviderDefined]`-annotated types are discovered automatically. Calling `ProviderDefinedTypeRegistry.Create()`
+scans all loaded assemblies for `[ProviderDefined]`-annotated types and registers them for hydration. No extra
+configuration is needed — providers simply annotate their types and users call `Create()` to create the registry.
+
+===== JavaScript
+
+Register hydration adapters explicitly on a `ProviderDefinedTypeRegistry` instance, then pass it to the connection:
+
+[source,javascript]
+----
+const registry = new ProviderDefinedTypeRegistry();
+registry.register('mygraph:Point', {
+    serialize: (obj) => ({ x: obj.x, y: obj.y }),
+    deserialize: (fields) => new Point(fields.x, fields.y)
+}, Point);
+----
+
+===== Go
+
+Register types on a `PDTRegistry` instance. Go supports either reflection-based registration (using `pdt` struct
+tags) or explicit function registration:
+
+[source,go]
+----
+registry := NewPDTRegistry()
+registry.RegisterType("mygraph:Point", reflect.TypeOf(Point{}))
+----
+
+===== Provider Factory Pattern
+
+Regardless of language, the recommended pattern is for providers to expose a factory method that returns a
+pre-configured `ProviderDefinedTypeRegistry`. This shields end users from needing to know which types exist or how
+the registry is populated:
+
+[source,java]
+----
+// In the provider's client library
+public class MyGraphTypeRegistry {
+    public static ProviderDefinedTypeRegistry create() {
+        ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.create(); // discovers ServiceLoader adapters
+        registry.register(Point.class, Address.class, Person.class);               // registers annotated types
+        return registry;
+    }
+}
+----
+
+End users configure their connection in one line:
+
+[source,java]
+----
+DriverRemoteConnection conn = DriverRemoteConnection.using(cluster);
+conn.setPdtRegistry(MyGraphTypeRegistry.create());
+GraphTraversalSource g = traversal().with(conn);
+----
+
+With this in place, `Point` objects round-trip transparently in both directions — the annotation handles outbound
+serialization and the registry handles inbound reconstruction.
+
+For driver users consuming PDTs, see the <<gremlin-variants,Gremlin Variants>> reference documentation for
+each language driver.
+
 [[gremlin-plugins]]
 == Gremlin Plugins
 
diff --git a/docs/src/reference/gremlin-variants.asciidoc b/docs/src/reference/gremlin-variants.asciidoc
index 27924fa360..7a8405b292 100644
--- a/docs/src/reference/gremlin-variants.asciidoc
+++ b/docs/src/reference/gremlin-variants.asciidoc
@@ -270,6 +270,7 @@ can be passed to the `NewClient` or `NewDriverRemoteConnection` functions as con
 More details can be found in provider docs
 link:https://tinkerpop.apache.org/docs/x.y.z/dev/provider/#_graph_driver_provider_requirements[here].|true
 |RequestInterceptors |Functions that modify HTTP requests before sending. Used for authentication and custom headers. |empty
+|PDTRegistry |A `*PDTRegistry` for hydrating and dehydrating <<gremlin-go-pdt,Provider Defined Types>>. |`nil`
 |=========================================================
 
 [[gremlin-go-strategies]]
@@ -655,6 +656,48 @@ go run basic_gremlin.go
 go run modern_traversals.go
 ----
 
+[[gremlin-go-pdt]]
+=== Provider Defined Types
+
+Provider Defined Types (PDTs) allow graph providers to expose custom types through the driver. PDT values arrive as
+`*ProviderDefinedType` structs containing a `Name` and `Fields` map without any configuration.
+Consult your graph provider's documentation for the list of PDTs they support.
+
+[source,go]
+----
+results, err := g.V().Has("location").Values("location").ToList()
+pdt := results[0].GetInterface().(*gremlingo.ProviderDefinedType)
+fmt.Println(pdt.Name)   // "x:Point"
+fmt.Println(pdt.Fields) // map[x:1.0 y:2.0]
+----
+
+Working with raw `*ProviderDefinedType` values is always available. Using a `PDTRegistry` is an optional
+convenience that automates conversion between PDT values and application types on both the request and response paths.
+
+Using a `PDTRegistry` for hydration and dehydration:
+
+[source,go]
+----
+registry := gremlingo.NewPDTRegistry()
+registry.RegisterFuncsWithType("x:Point", reflect.TypeOf(Point{}),
+    // hydrate: convert incoming PDT fields map to a Go type
+    func(fields map[string]interface{}) (interface{}, error) {
+        return &Point{X: fields["x"].(float64), Y: fields["y"].(float64)}, nil
+    },
+    // dehydrate: convert a Go type to a PDT fields map for sending
+    func(obj interface{}) (map[string]interface{}, error) {
+        p := obj.(*Point)
+        return map[string]interface{}{"x": p.X, "y": p.Y}, nil
+    },
+)
+
+remote, _ := gremlingo.NewDriverRemoteConnection("http://localhost:8182/gremlin",
+    func(settings *gremlingo.DriverRemoteConnectionSettings) {
+        settings.PDTRegistry = registry
+    })
+g := gremlingo.Traversal_().With(remote)
+----
+
 [[gremlin-groovy]]
 == Gremlin-Groovy
 
@@ -1544,6 +1587,72 @@ java -cp target/run-examples-shaded.jar examples.BasicGremlin
 java -cp target/run-examples-shaded.jar examples.ModernTraversals
 ----
 
+[[gremlin-java-pdt]]
+=== Provider Defined Types
+
+Provider Defined Types (PDTs) allow graph providers to expose custom types through the driver. PDT values arrive as
+`ProviderDefinedType` objects containing a name and fields map without any configuration.
+Consult your graph provider's documentation for the list of PDTs they support.
+
+Receiving a raw PDT:
+
+[source,java]
+----
+ProviderDefinedType pdt = (ProviderDefinedType) g.V().has("location").values("location").next();
+String typeName = pdt.getName();                   // "x:Point"
+Map<String, Object> fields = pdt.getFields();      // {x: 1.0, y: 2.0}
+----
+
+Working with raw `ProviderDefinedType` objects is always available. The following two approaches are optional
+conveniences that automate conversion between PDT values and application types on both the request and response paths.
+
+Using a `ProviderDefinedTypeRegistry` for hydration and dehydration:
+----
+public class PointAdapter implements ProviderDefinedTypeAdapter<Point> {
+    @Override public String typeName() { return "x:Point"; }
+    @Override public Class<Point> targetClass() { return Point.class; }
+    @Override public Map<String, Object> toFields(Point p) { return Map.of("x", p.getX(), "y", p.getY()); }
+    @Override public Point fromFields(Map<String, Object> m) { return new Point((double) m.get("x"), (double) m.get("y")); }
+}
+----
+
+Register adapters via ServiceLoader by adding the fully qualified class name to
+`META-INF/services/org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeAdapter`. The driver discovers
+adapters on the classpath and automatically hydrates/dehydrates.
+
+For simpler cases where you own the type, annotate it directly to avoid writing an adapter:
+
+Annotation-based conversion with `@ProviderDefined`:
+
+[source,java]
+----
+// includedFields: only serialize the listed fields
+@ProviderDefined(name = "x:Point", includedFields = {"x", "y"})
+public class Point {
+    private final double x;
+    private final double y;
+    private final String internalId; // not serialized
+    // constructor, getters...
+}
+
+// excludedFields: serialize all fields except those listed
+@ProviderDefined(name = "x:Timestamped", excludedFields = {"createdAt"})
+public class Timestamped {
+    private final String value;
+    private final long createdAt; // not serialized
+    // constructor, getters...
+}
+
+// send: Point is automatically dehydrated
+g.inject(new Point(1.0, 2.0, "internal")).iterate();
+
+// receive: PDT is automatically hydrated back to Point
+Point p = (Point) g.V().has("location").values("location").next();
+----
+
+Classes annotated with `@ProviderDefined` are automatically dehydrated when passed as traversal arguments and
+hydrated on deserialization without explicit registry configuration.
+
 [[gremlin-javascript]]
 == Gremlin-JavaScript
 
@@ -1722,6 +1831,7 @@ can be passed in the constructor of a new `Client` or `DriverRemoteConnection` :
 |options.writer |GraphBinaryWriter |The writer to use for serializing requests. |GraphBinaryWriter
 |options.enableUserAgentOnConnect |Boolean |Determines if a user agent header will be sent with requests. |true
 |options.agent |Agent |A custom `node:http` or `node:https` Agent for connection pooling or proxy configuration. |undefined
+|options.pdtRegistry |ProviderDefinedTypeRegistry |A registry for hydrating and dehydrating <<gremlin-javascript-pdt,Provider Defined Types>>. |undefined
 |=========================================================
 
 [[gremlin-javascript-logging]]
@@ -2171,6 +2281,42 @@ node basic-gremlin.js
 node modern-traversals.js
 ----
 
+[[gremlin-javascript-pdt]]
+=== Provider Defined Types
+
+Provider Defined Types (PDTs) allow graph providers to expose custom types through the driver. PDT values arrive as
+`ProviderDefinedType` objects containing a `name` and `fields` map without any configuration.
+Consult your graph provider's documentation for the list of PDTs they support.
+
+Receiving a raw PDT:
+
+[source,javascript]
+----
+const results = await g.V().has('location').values('location').toList();
+const pdt = results[0];
+console.log(pdt.name);   // "x:Point"
+console.log(pdt.fields); // { x: 1.0, y: 2.0 }
+----
+
+Working with raw `ProviderDefinedType` objects is always available. Using a `ProviderDefinedTypeRegistry` is an
+optional convenience that automates conversion between PDT values and application types on both the request and
+response paths.
+
+[source,javascript]
+----
+const { ProviderDefinedTypeRegistry } = require('gremlin');
+
+const registry = new ProviderDefinedTypeRegistry();
+registry.register('x:Point', {
+    serialize: (point) => ({ x: point.x, y: point.y }),
+    deserialize: (fields) => new Point(fields.x, fields.y)
+}, Point);
+
+const g = traversal().with_(new DriverRemoteConnection('http://localhost:8182/gremlin', {
+    pdtRegistry: registry
+}));
+----
+
 anchor:gremlin-DotNet[]
 [[gremlin-dotnet]]
 == Gremlin.Net
@@ -2311,6 +2457,7 @@ The following options can be passed to the `GremlinClient` constructor:
 |connectionSettings |The `ConnectionSettings` for the HTTP connection. |default `ConnectionSettings`
 |loggerFactory |An `ILoggerFactory` for logging. |`NullLoggerFactory`
 |interceptors |A list of `Func<HttpRequestContext, Task>` that modify HTTP requests before sending. |_none_
+|pdtRegistry |A `ProviderDefinedTypeRegistry` for hydrating and dehydrating <<gremlin-dotnet-pdt,Provider Defined Types>>. |`null`
 |=========================================================
 
 [[gremlin-dotnet-logging]]
@@ -2652,6 +2799,82 @@ dotnet run --project Connections
 dotnet run --project ModernTraversals
 ----
 
+[[gremlin-dotnet-pdt]]
+=== Provider Defined Types
+
+Provider Defined Types (PDTs) allow graph providers to expose custom types through the driver. PDT values arrive as
+`ProviderDefinedType` objects containing a `Name` and `Fields` dictionary without any configuration.
+Consult your graph provider's documentation for the list of PDTs they support.
+
+Receiving a raw PDT:
+
+[source,csharp]
+----
+var pdt = (ProviderDefinedType) g.V().Has("location").Values<object>("location").Next();
+Console.WriteLine(pdt.Name);   // "x:Point"
+Console.WriteLine(pdt.Fields); // { x: 1.0, y: 2.0 }
+----
+
+Working with raw `ProviderDefinedType` objects is always available. The following two approaches are optional
+conveniences that automate conversion between PDT values and application types on both the request and response paths.
+
+Using a `ProviderDefinedTypeRegistry` for hydration and dehydration:
+
+[source,csharp]
+----
+public class PointAdapter : IProviderDefinedTypeAdapter<Point>
+{
+    public string TypeName => "x:Point";
+
+    public Point FromFields(IReadOnlyDictionary<string, object?> fields) =>
+        new Point((double)fields["x"], (double)fields["y"]);
+
+    public IReadOnlyDictionary<string, object?> ToFields(Point value) =>
+        new Dictionary<string, object?> { ["x"] = value.X, ["y"] = value.Y };
+}
+
+var registry = new ProviderDefinedTypeRegistry();
+registry.Register(new PointAdapter());
+
+using var client = new GremlinClient(new GremlinServer("localhost", 8182), pdtRegistry: registry);
+----
+
+The `ProviderDefinedTypeRegistry.Create()` method scans loaded assemblies for `IProviderDefinedTypeAdapter<T>`
+implementations and registers them automatically.
+
+For simpler cases where you own the type, annotate it directly to avoid writing an adapter:
+
+Attribute-based conversion with `[ProviderDefined]`:
+
+[source,csharp]
+----
+// IncludedFields: only serialize the listed fields
+[ProviderDefined(Name = "x:Point", IncludedFields = new[] { "X", "Y" })]
+public class Point
+{
+    public double X { get; set; }
+    public double Y { get; set; }
+    public string InternalId { get; set; } // not serialized
+}
+
+// ExcludedFields: serialize all fields except those listed
+[ProviderDefined(Name = "x:Timestamped", ExcludedFields = new[] { "CreatedAt" })]
+public class Timestamped
+{
+    public string Value { get; set; }
+    public long CreatedAt { get; set; } // not serialized
+}
+
+// send: Point is automatically dehydrated
+await g.Inject(new Point { X = 1.0, Y = 2.0 }).Promise(t => t.Iterate());
+
+// receive: PDT is automatically hydrated back to Point
+var p = (Point) await g.V().Has("location").Values<object>("location").Promise(t => t.Next());
+----
+
+Classes annotated with `[ProviderDefined]` are automatically dehydrated when passed as traversal arguments and
+hydrated on deserialization without explicit registry configuration.
+
 [[gremlin-python]]
 == Gremlin-Python
 
@@ -2841,6 +3064,7 @@ can be passed to the `Client` or `DriverRemoteConnection` instance as keyword ar
 More details can be found in provider docs
 link:https://tinkerpop.apache.org/docs/x.y.z/dev/provider/#_graph_driver_provider_requirements[here].|True
 |bulk_results |Enables bulking of results on the server. |False
+|pdt_registry |A `ProviderDefinedTypeRegistry` for hydrating and dehydrating <<gremlin-python-pdt,Provider Defined Types>>. |`None`
 |=========================================================
 
 Transport options such as SSL and timeouts can be passed as keyword arguments to `Client` or
@@ -3254,3 +3478,76 @@ python connections.py
 python basic_gremlin.py
 python modern_traversals.py
 ----
+
+[[gremlin-python-pdt]]
+=== Provider Defined Types
+
+Provider Defined Types (PDTs) allow graph providers to expose custom types through the driver. PDT values arrive as
+`ProviderDefinedType` objects containing a `name` and `fields` dict without any configuration.
+Consult your graph provider's documentation for the list of PDTs they support.
+
+Receiving a raw PDT:
+
+[source,python]
+----
+pdt = g.V().has('location').values('location').next()
+print(pdt.name)   # "x:Point"
+print(pdt.fields) # {'x': 1.0, 'y': 2.0}
+----
+
+Working with raw `ProviderDefinedType` objects is always available. The following two approaches are optional
+conveniences that automate conversion between PDT values and application types on both the request and response paths.
+
+Using a `ProviderDefinedTypeRegistry` for hydration and dehydration:
+
+[source,python]
+----
+from gremlin_python.structure.graph import ProviderDefinedTypeRegistry
+
+registry = ProviderDefinedTypeRegistry()
+registry.register('x:Point',
+    deserialize_fn=lambda fields: Point(fields['x'], fields['y']),
+    serialize_fn=lambda p: {'x': p.x, 'y': p.y},
+    target_class=Point)
+
+g = traversal().with_(DriverRemoteConnection('http://localhost:8182/gremlin', 'g',
+    pdt_registry=registry))
+----
+
+The `ProviderDefinedTypeRegistry.create()` class method discovers adapters via `entry_points` in `pyproject.toml`
+under the `tinkerpop.pdt` group.
+
+For simpler cases where you own the type, the `@provider_defined` decorator enables automatic round-trip conversion
+without an explicit registry:
+
+Decorator-based conversion with `@provider_defined`:
+
+[source,python]
+----
+from gremlin_python.structure.graph import provider_defined
+
+# included_fields: only serialize the listed fields
+@provider_defined(name='x:Point', included_fields=['x', 'y'])
+class Point:
+    def __init__(self, x, y):
+        self.x = x
+        self.y = y
+        self._internal_id = None  # not serialized
+
+# excluded_fields: serialize all instance fields except those listed
+@provider_defined(name='x:Timestamped', excluded_fields=['created_at'])
+class Timestamped:
+    def __init__(self, value, created_at):
+        self.value = value
+        self.created_at = created_at  # not serialized
+
+# send: Point is automatically dehydrated
+g.inject(Point(1.0, 2.0)).iterate()
+
+# receive: PDT is automatically hydrated back to Point
+p = g.V().has('location').values('location').next()
+# type(p) is Point
+----
+
+Objects decorated with `@provider_defined` are automatically dehydrated when passed as traversal arguments and
+hydrated back into the same type when received in responses.
\ No newline at end of file
diff --git a/docs/src/upgrade/release-4.x.x.asciidoc b/docs/src/upgrade/release-4.x.x.asciidoc
index 4706775c7f..ac8debb0a9 100644
--- a/docs/src/upgrade/release-4.x.x.asciidoc
+++ b/docs/src/upgrade/release-4.x.x.asciidoc
@@ -530,10 +530,41 @@ It cannot be passed to `traversal().with(...)`, and mutating its collections has
 re-query elements against the original graph, extract their ids and call `g.V(id)` or `g.E(id)` against the original
 `GraphTraversalSource`.
 
+==== Provider Defined Types
+
+Graph providers may now expose custom types as Provider Defined Types (PDT) (replacing the old `CustomTypeSerializer`
+mechanism). The key improvement is that the default case now works out of the box — drivers deserialize PDT values
+as `ProviderDefinedType` objects containing a `name` and a `fields` map without any configuration, eliminating
+the serializer errors that occurred with unknown custom types in TP3. For automatic conversion between PDT values and
+application-specific types, each driver supports an optional registry or annotation mechanism, which requires similar
+effort to the old custom serializer approach but is entirely optional for basic usage:
+
+* <<gremlin-java-pdt,Gremlin-Java>>
+* <<gremlin-python-pdt,Gremlin-Python>>
+* <<gremlin-javascript-pdt,Gremlin-JavaScript>>
+* <<gremlin-dotnet-pdt,Gremlin.Net>>
+* <<gremlin-go-pdt,Gremlin-Go>>
+
+
 === Upgrading for Providers
 
 ==== Graph System Providers
 
+===== Provider Defined Types
+
+TinkerPop 4 replaces the TP3 `CustomTypeSerializer` mechanism with Provider Defined Types (PDT). The TP3 extension
+point has been removed entirely and there is no backward-compatible bridge, so any existing `CustomTypeSerializer`
+implementations must be migrated: providers expose their custom types by annotating classes with `@ProviderDefined`
+or, for types they don't own, by implementing `ProviderDefinedTypeAdapter<T>`. Adapters are discovered automatically
+via ServiceLoader, so this requires similar effort to the old approach.
+
+The key improvement is that driver users now receive PDT values as structured `ProviderDefinedType` objects by
+default, without any configuration — eliminating the serializer errors that unknown custom types caused in TP3, and
+benefiting all driver users transparently.
+
+See <<provider-defined-types>> for full details on annotation usage, field filtering, nested types, and ServiceLoader
+registration.
+
 ==== Graph Driver Providers
 
 == TinkerPop 4.0.0-beta.2
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/grammar/DefaultGremlinBaseVisitor.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/grammar/DefaultGremlinBaseVisitor.java
index 550774cd27..760da5be7a 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/grammar/DefaultGremlinBaseVisitor.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/grammar/DefaultGremlinBaseVisitor.java
@@ -1355,6 +1355,10 @@ public class DefaultGremlinBaseVisitor<T> extends AbstractParseTreeVisitor<T> im
      * {@inheritDoc}
      */
     @Override public T visitBinaryLiteral(final GremlinParser.BinaryLiteralContext ctx) { notImplemented(ctx); return null; }
+    /**
+	 * {@inheritDoc}
+	 */
+    @Override public T visitPdtLiteral(final GremlinParser.PdtLiteralContext ctx) { notImplemented(ctx); return null; }
     /**
 	 * {@inheritDoc}
 	 */
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/grammar/GenericLiteralVisitor.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/grammar/GenericLiteralVisitor.java
index 67dcd47665..83fc03daf4 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/grammar/GenericLiteralVisitor.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/grammar/GenericLiteralVisitor.java
@@ -27,6 +27,7 @@ import org.apache.tinkerpop.gremlin.process.traversal.Traversal;
 import org.apache.tinkerpop.gremlin.process.traversal.TraversalStrategy;
 import org.apache.tinkerpop.gremlin.structure.T;
 import org.apache.tinkerpop.gremlin.structure.VertexProperty;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType;
 import org.apache.tinkerpop.gremlin.util.DatetimeHelper;
 
 import java.math.BigDecimal;
@@ -578,6 +579,22 @@ public class GenericLiteralVisitor extends DefaultGremlinBaseVisitor<Object> {
         }
     }
 
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public Object visitPdtLiteral(final GremlinParser.PdtLiteralContext ctx) {
+        final String name = (String) visitStringLiteral(ctx.stringLiteral());
+        final Map<String, Object> fields = new LinkedHashMap<>();
+        final Map<?, ?> rawMap = (Map<?, ?>) visitGenericMapLiteral(ctx.genericMapLiteral());
+        for (final Map.Entry<?, ?> entry : rawMap.entrySet()) {
+            if (!(entry.getKey() instanceof String))
+                throw new IllegalArgumentException("PDT fields map must have String keys, found: " + entry.getKey().getClass().getName());
+            fields.put((String) entry.getKey(), entry.getValue());
+        }
+        return new ProviderDefinedType(name, fields);
+    }
+
     /**
      * {@inheritDoc}
      */
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/AnonymizedTranslatorVisitor.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/AnonymizedTranslatorVisitor.java
index 23b70526bc..40f869dac3 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/AnonymizedTranslatorVisitor.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/AnonymizedTranslatorVisitor.java
@@ -20,6 +20,7 @@ package org.apache.tinkerpop.gremlin.language.translator;
 
 import org.antlr.v4.runtime.ParserRuleContext;
 import org.apache.tinkerpop.gremlin.language.grammar.GremlinParser;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType;
 
 import java.math.BigDecimal;
 import java.math.BigInteger;
@@ -204,4 +205,9 @@ public class AnonymizedTranslatorVisitor extends TranslateVisitor {
     public Void visitBinaryLiteral(final GremlinParser.BinaryLiteralContext ctx) {
         return anonymize(ctx, ByteBuffer.class);
     }
+
+    @Override
+    public Void visitPdtLiteral(final GremlinParser.PdtLiteralContext ctx) {
+        return anonymize(ctx, ProviderDefinedType.class);
+    }
 }
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/DotNetTranslateVisitor.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/DotNetTranslateVisitor.java
index 11546c6ca2..7c296572cf 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/DotNetTranslateVisitor.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/DotNetTranslateVisitor.java
@@ -1207,6 +1207,16 @@ public class DotNetTranslateVisitor extends AbstractTranslateVisitor {
         return null;
     }
 
+    @Override
+    public Void visitPdtLiteral(final GremlinParser.PdtLiteralContext ctx) {
+        sb.append("new ProviderDefinedType(");
+        sb.append(ctx.stringLiteral().getText());
+        sb.append(", ");
+        visitGenericMapLiteral(ctx.genericMapLiteral());
+        sb.append(")");
+        return null;
+    }
+
     @Override
     public Void visitBinaryLiteral(final GremlinParser.BinaryLiteralContext ctx) {
         sb.append("Convert.FromBase64String(");
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/GoTranslateVisitor.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/GoTranslateVisitor.java
index fa3633800d..f4094e541e 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/GoTranslateVisitor.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/GoTranslateVisitor.java
@@ -376,6 +376,16 @@ public class GoTranslateVisitor extends AbstractTranslateVisitor {
         return null;
     }
 
+    @Override
+    public Void visitPdtLiteral(final GremlinParser.PdtLiteralContext ctx) {
+        sb.append("&gremlingo.ProviderDefinedType{Name: ");
+        visitStringLiteral(ctx.stringLiteral());
+        sb.append(", Fields: ");
+        visitGenericMapLiteral(ctx.genericMapLiteral());
+        sb.append("}");
+        return null;
+    }
+
     @Override
     public Void visitBinaryLiteral(final GremlinParser.BinaryLiteralContext ctx) {
         final String base64Str = removeFirstAndLastCharacters(ctx.stringLiteral().getText());
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/GroovyTranslateVisitor.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/GroovyTranslateVisitor.java
index 009233c9eb..2fd655a506 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/GroovyTranslateVisitor.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/GroovyTranslateVisitor.java
@@ -152,6 +152,16 @@ public class GroovyTranslateVisitor extends TranslateVisitor {
         return null;
     }
 
+    @Override
+    public Void visitPdtLiteral(final GremlinParser.PdtLiteralContext ctx) {
+        sb.append("new ProviderDefinedType(");
+        sb.append(ctx.stringLiteral().getText());
+        sb.append(", ");
+        visitGenericMapLiteral(ctx.genericMapLiteral());
+        sb.append(")");
+        return null;
+    }
+
     @Override
     public Void visitBinaryLiteral(final GremlinParser.BinaryLiteralContext ctx) {
         sb.append("ByteBuffer.wrap(Base64.getDecoder().decode(");
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/JavaTranslateVisitor.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/JavaTranslateVisitor.java
index ee03430118..6f28494526 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/JavaTranslateVisitor.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/JavaTranslateVisitor.java
@@ -270,6 +270,16 @@ public class JavaTranslateVisitor extends AbstractTranslateVisitor {
         return null;
     }
 
+    @Override
+    public Void visitPdtLiteral(final GremlinParser.PdtLiteralContext ctx) {
+        sb.append("new ProviderDefinedType(");
+        sb.append(ctx.stringLiteral().getText());
+        sb.append(", ");
+        visitGenericMapLiteral(ctx.genericMapLiteral());
+        sb.append(")");
+        return null;
+    }
+
     @Override
     public Void visitBinaryLiteral(final GremlinParser.BinaryLiteralContext ctx) {
         sb.append("ByteBuffer.wrap(Base64.getDecoder().decode(");
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/JavascriptTranslateVisitor.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/JavascriptTranslateVisitor.java
index 2a8e0ed07f..cf6284d275 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/JavascriptTranslateVisitor.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/JavascriptTranslateVisitor.java
@@ -240,6 +240,16 @@ public class JavascriptTranslateVisitor extends AbstractTranslateVisitor {
         throw new TranslatorException("Duration literals are not supported in JavaScript");
     }
 
+    @Override
+    public Void visitPdtLiteral(final GremlinParser.PdtLiteralContext ctx) {
+        sb.append("new ProviderDefinedType(");
+        visitStringLiteral(ctx.stringLiteral());
+        sb.append(", ");
+        visitGenericMapLiteral(ctx.genericMapLiteral());
+        sb.append(")");
+        return null;
+    }
+
     @Override
     public Void visitBinaryLiteral(final GremlinParser.BinaryLiteralContext ctx) {
         sb.append("Buffer.from(");
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/PythonTranslateVisitor.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/PythonTranslateVisitor.java
index e6297dc539..694d6b4238 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/PythonTranslateVisitor.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/PythonTranslateVisitor.java
@@ -317,6 +317,16 @@ public class PythonTranslateVisitor extends AbstractTranslateVisitor {
         return null;
     }
 
+    @Override
+    public Void visitPdtLiteral(final GremlinParser.PdtLiteralContext ctx) {
+        sb.append("ProviderDefinedType(");
+        visitStringLiteral(ctx.stringLiteral());
+        sb.append(", ");
+        visitGenericMapLiteral(ctx.genericMapLiteral());
+        sb.append(")");
+        return null;
+    }
+
     @Override
     public Void visitBinaryLiteral(final GremlinParser.BinaryLiteralContext ctx) {
         sb.append("base64.b64decode(");
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/TranslateVisitor.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/TranslateVisitor.java
index a8d51f6b28..7e39b31482 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/TranslateVisitor.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/language/translator/TranslateVisitor.java
@@ -470,6 +470,12 @@ public class TranslateVisitor extends GremlinBaseVisitor<Void> {
         return null;
     }
 
+    @Override
+    public Void visitPdtLiteral(final GremlinParser.PdtLiteralContext ctx) {
+        sb.append(ctx.getText());
+        return null;
+    }
+
     @Override
     public Void visitVariable(final GremlinParser.VariableContext ctx) {
         final String var = ctx.getText();
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/remote/RemoteConnection.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/remote/RemoteConnection.java
index 15be3637ca..607c2b480f 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/remote/RemoteConnection.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/remote/RemoteConnection.java
@@ -23,6 +23,7 @@ import org.apache.tinkerpop.gremlin.process.remote.traversal.RemoteTraversal;
 import org.apache.tinkerpop.gremlin.process.traversal.GremlinLang;
 import org.apache.tinkerpop.gremlin.process.traversal.Traversal;
 import org.apache.tinkerpop.gremlin.structure.Transaction;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeRegistry;
 
 import java.lang.reflect.Constructor;
 import java.util.Iterator;
@@ -55,6 +56,14 @@ public interface RemoteConnection extends AutoCloseable {
      */
     public <E> CompletableFuture<RemoteTraversal<?, E>> submitAsync(final GremlinLang gremlinLang) throws RemoteConnectionException;
 
+    /**
+     * Returns the {@link ProviderDefinedTypeRegistry} associated with this connection, or {@code null} if none.
+     * Used by the gremlin-lang translator for registry-based dehydration of unknown types.
+     */
+    public default ProviderDefinedTypeRegistry getPdtRegistry() {
+        return null;
+    }
+
     /**
      * Create a {@link RemoteConnection} from a {@code Configuration} object. The configuration must contain a
      * {@code gremlin.remote.remoteConnectionClass} key which is the fully qualified class name of a
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/traversal/GremlinLang.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/traversal/GremlinLang.java
index 08f124015c..3bd1cb5287 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/traversal/GremlinLang.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/traversal/GremlinLang.java
@@ -30,6 +30,10 @@ import org.apache.tinkerpop.gremlin.process.traversal.util.DefaultTraversal;
 import org.apache.tinkerpop.gremlin.structure.Column;
 import org.apache.tinkerpop.gremlin.structure.T;
 import org.apache.tinkerpop.gremlin.structure.Vertex;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefined;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeAdapter;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeRegistry;
 import org.apache.tinkerpop.gremlin.util.NumberHelper;
 
 import javax.lang.model.SourceVersion;
@@ -48,6 +52,7 @@ import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.Set;
 import java.util.UUID;
 import java.util.Base64;
@@ -67,10 +72,15 @@ public class GremlinLang implements Cloneable, Serializable {
     private Map<String, Object> parameters = new HashMap<>();
     private String unsupportedType = "";
     private List<OptionsStrategy> optionsStrategies = new ArrayList<>();
+    private ProviderDefinedTypeRegistry pdtRegistry;
 
     public GremlinLang() {
     }
 
+    public GremlinLang(final ProviderDefinedTypeRegistry pdtRegistry) {
+        this.pdtRegistry = pdtRegistry;
+    }
+
     public GremlinLang(final String sourceName, final Object... arguments) {
         addToGremlin(sourceName, arguments);
     }
@@ -179,6 +189,11 @@ public class GremlinLang implements Cloneable, Serializable {
             return String.format("Binary(\"%s\")", Base64.getEncoder().encodeToString((byte[]) arg));
         }
 
+        if (arg instanceof ProviderDefinedType) {
+            final ProviderDefinedType pdt = (ProviderDefinedType) arg;
+            return "PDT(" + argAsString(pdt.getName()) + "," + asString((Map<?, ?>) pdt.getFields()) + ")";
+        }
+
         if (arg instanceof Enum) {
             // special handling for enums with additional interfaces
             if (arg instanceof T)
@@ -252,6 +267,21 @@ public class GremlinLang implements Cloneable, Serializable {
             return ((Class) arg).getSimpleName();
         }
 
+        // Intentional precedence: a registered adapter takes priority over @ProviderDefined annotation
+        // so that providers/users can override annotation-derived behavior with an explicit adapter.
+        if (pdtRegistry != null) {
+            final Optional<ProviderDefinedTypeAdapter<?>> adapter = pdtRegistry.getAdapterByClass(arg.getClass());
+            if (adapter.isPresent()) {
+                @SuppressWarnings("unchecked")
+                final Map<String, Object> fields = ((ProviderDefinedTypeAdapter) adapter.get()).toFields(arg);
+                return argAsString(new ProviderDefinedType(adapter.get().typeName(), fields));
+            }
+        }
+
+        if (arg.getClass().isAnnotationPresent(ProviderDefined.class)) {
+            return argAsString(ProviderDefinedType.from(arg));
+        }
+
         unsupportedType = arg.getClass().getSimpleName();
         return arg.toString();
     }
@@ -532,6 +562,20 @@ public class GremlinLang implements Cloneable, Serializable {
         return optionsStrategies;
     }
 
+    /**
+     * Sets the {@link ProviderDefinedTypeRegistry} used for registry-based dehydration of unknown types.
+     */
+    public void setPdtRegistry(final ProviderDefinedTypeRegistry pdtRegistry) {
+        this.pdtRegistry = pdtRegistry;
+    }
+
+    /**
+     * Gets the {@link ProviderDefinedTypeRegistry} used for registry-based dehydration.
+     */
+    public ProviderDefinedTypeRegistry getPdtRegistry() {
+        return this.pdtRegistry;
+    }
+
     public boolean isEmpty() {
         return this.gremlin.length() == 0;
     }
@@ -565,6 +609,7 @@ public class GremlinLang implements Cloneable, Serializable {
             clone.gremlin.append(gremlin);
             clone.optionsStrategies = new ArrayList<>(this.optionsStrategies);
             clone.unsupportedType = this.unsupportedType;
+            clone.pdtRegistry = this.pdtRegistry;
             return clone;
         } catch (final CloneNotSupportedException e) {
             throw new IllegalStateException(e.getMessage(), e);
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/traversal/dsl/graph/GraphTraversalSource.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/traversal/dsl/graph/GraphTraversalSource.java
index 684d9e8609..aec1d6fadd 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/traversal/dsl/graph/GraphTraversalSource.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/process/traversal/dsl/graph/GraphTraversalSource.java
@@ -102,6 +102,9 @@ public class GraphTraversalSource implements TraversalSource {
         this(EmptyGraph.instance(), TraversalStrategies.GlobalCache.getStrategies(EmptyGraph.class).clone());
         this.connection = connection;
         this.strategies.addStrategies(new RemoteStrategy(connection));
+        if (connection.getPdtRegistry() != null) {
+            this.gremlinLang.setPdtRegistry(connection.getPdtRegistry());
+        }
     }
 
     @Override
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/DataType.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/DataType.java
index 7c2e347f6e..6ca99db840 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/DataType.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/DataType.java
@@ -58,7 +58,8 @@ public enum DataType {
     CHAR(0X80),
     DURATION(0X81),
 
-    CUSTOM(0),
+    COMPOSITE_PDT(0xF0),
+
     MARKER(0XFD),
     UNSPECIFIED_NULL(0XFE);
 
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/GraphBinaryIo.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/GraphBinaryIo.java
deleted file mode 100644
index 8a1e2c0e4e..0000000000
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/GraphBinaryIo.java
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-package org.apache.tinkerpop.gremlin.structure.io.binary;
-
-import org.apache.tinkerpop.gremlin.structure.io.GraphReader;
-import org.apache.tinkerpop.gremlin.structure.io.GraphWriter;
-import org.apache.tinkerpop.gremlin.structure.io.Io;
-import org.apache.tinkerpop.gremlin.structure.io.IoRegistry;
-import org.apache.tinkerpop.gremlin.structure.io.Mapper;
-
-import java.io.IOException;
-
-/**
- * This is a dummy implementation of {@link Io} which is only used in the context of helping to configure a
- * GraphBinary {@code MessageSerializer} with an {@link IoRegistry}. It's methods are not implemented.
- *
- * @author Stephen Mallette (http://stephen.genoprime.com)
- */
-public class GraphBinaryIo implements Io {
-    @Override
-    public GraphReader.ReaderBuilder reader() {
-        throw new UnsupportedOperationException("GraphBinaryIo is only used to support IoRegistry configuration - it's methods are not implemented");
-    }
-
-    @Override
-    public GraphWriter.WriterBuilder writer() {
-        throw new UnsupportedOperationException("GraphBinaryIo is only used to support IoRegistry configuration - it's methods are not implemented");
-    }
-
-    @Override
-    public Mapper.Builder mapper() {
-        throw new UnsupportedOperationException("GraphBinaryIo is only used to support IoRegistry configuration - it's methods are not implemented");
-    }
-
-    @Override
-    public void writeGraph(final String file) throws IOException {
-        throw new UnsupportedOperationException("GraphBinaryIo is only used to support IoRegistry configuration - it's methods are not implemented");
-    }
-
-    @Override
-    public void readGraph(final String file) throws IOException {
-        throw new UnsupportedOperationException("GraphBinaryIo is only used to support IoRegistry configuration - it's methods are not implemented");
-    }
-}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/GraphBinaryReader.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/GraphBinaryReader.java
index fb3ef553eb..5f3cf240c6 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/GraphBinaryReader.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/GraphBinaryReader.java
@@ -19,6 +19,8 @@
 package org.apache.tinkerpop.gremlin.structure.io.binary;
 
 import org.apache.tinkerpop.gremlin.structure.io.Buffer;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeRegistry;
 
 import java.io.IOException;
 
@@ -48,13 +50,19 @@ import java.io.IOException;
  */
 public class GraphBinaryReader {
     private final TypeSerializerRegistry registry;
+    private final ProviderDefinedTypeRegistry pdtRegistry;
 
     public GraphBinaryReader() {
         this(TypeSerializerRegistry.INSTANCE);
     }
 
     public GraphBinaryReader(final TypeSerializerRegistry registry) {
+        this(registry, null);
+    }
+
+    public GraphBinaryReader(final TypeSerializerRegistry registry, final ProviderDefinedTypeRegistry pdtRegistry) {
         this.registry = registry;
+        this.pdtRegistry = pdtRegistry;
     }
 
     /**
@@ -95,14 +103,11 @@ public class GraphBinaryReader {
             return null;
         }
 
-        TypeSerializer<T> serializer;
-        if (type != DataType.CUSTOM) {
-            serializer = registry.getSerializer(type);
-        } else {
-            final String customTypeName = this.readValue(buffer, String.class, false);
-            serializer = registry.getSerializerForCustomType(customTypeName);
+        final TypeSerializer<T> serializer = registry.getSerializer(type);
+        final T result = serializer.read(buffer, this);
+        if (pdtRegistry != null && result instanceof ProviderDefinedType) {
+            return (T) pdtRegistry.hydrate((ProviderDefinedType) result);
         }
-
-        return serializer.read(buffer, this);
+        return result;
     }
 }
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/GraphBinaryWriter.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/GraphBinaryWriter.java
index 5719015388..ef4c07ccda 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/GraphBinaryWriter.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/GraphBinaryWriter.java
@@ -18,8 +18,9 @@
  */
 package org.apache.tinkerpop.gremlin.structure.io.binary;
 
-import org.apache.tinkerpop.gremlin.structure.io.binary.types.CustomTypeSerializer;
+import org.apache.tinkerpop.gremlin.structure.io.binary.types.ProviderDefinedTypeSerializer;
 import org.apache.tinkerpop.gremlin.structure.io.binary.types.TransformSerializer;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType;
 import org.apache.tinkerpop.gremlin.structure.io.Buffer;
 
 import java.io.IOException;
@@ -50,7 +51,6 @@ public class GraphBinaryWriter {
     public final static byte VERSION_BYTE = (byte)0x84;
     public final static byte BULKED_BYTE = (byte)0x01;
     private final static byte[] unspecifiedNullBytes = new byte[] { DataType.UNSPECIFIED_NULL.getCodeByte(), 0x01};
-    private final static byte[] customTypeCodeBytes = new byte[] { DataType.CUSTOM.getCodeByte() };
 
     public GraphBinaryWriter() {
         this(TypeSerializerRegistry.INSTANCE);
@@ -76,6 +76,10 @@ public class GraphBinaryWriter {
         final Class<?> objectClass = value.getClass();
 
         final TypeSerializer<T> serializer = (TypeSerializer<T>) registry.getSerializer(objectClass);
+        if (serializer instanceof ProviderDefinedTypeSerializer && !(value instanceof ProviderDefinedType)) {
+            serializer.writeValue((T) ProviderDefinedType.from(value), buffer, this, nullable);
+            return;
+        }
         serializer.writeValue(value, buffer, this, nullable);
     }
 
@@ -92,13 +96,11 @@ public class GraphBinaryWriter {
         final Class<?> objectClass = value.getClass();
         final TypeSerializer<T> serializer = (TypeSerializer<T>) registry.getSerializer(objectClass);
 
-        if (serializer instanceof CustomTypeSerializer) {
-            // It's a custom type
-            CustomTypeSerializer customTypeSerializer = (CustomTypeSerializer) serializer;
-
-            buffer.writeBytes(customTypeCodeBytes);
-            writeValue(customTypeSerializer.getTypeName(), buffer, false);
-            customTypeSerializer.write(value, buffer, this);
+        if (serializer instanceof ProviderDefinedTypeSerializer && !(value instanceof ProviderDefinedType)) {
+            // Convert @ProviderDefined-annotated object to ProviderDefinedType, then re-enter write().
+            // On re-entry, ProviderDefinedType.class is directly registered in the registry,
+            // and the instanceof guard prevents double-wrapping.
+            write((T) ProviderDefinedType.from(value), buffer);
             return;
         }
 
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/TypeSerializerRegistry.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/TypeSerializerRegistry.java
index f3d9921252..c0a3234aac 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/TypeSerializerRegistry.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/TypeSerializerRegistry.java
@@ -31,13 +31,13 @@ import org.apache.tinkerpop.gremlin.structure.Property;
 import org.apache.tinkerpop.gremlin.structure.T;
 import org.apache.tinkerpop.gremlin.structure.Vertex;
 import org.apache.tinkerpop.gremlin.structure.VertexProperty;
-import org.apache.tinkerpop.gremlin.structure.io.IoRegistry;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefined;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType;
 import org.apache.tinkerpop.gremlin.structure.io.binary.types.BigDecimalSerializer;
 import org.apache.tinkerpop.gremlin.structure.io.binary.types.BigIntegerSerializer;
 import org.apache.tinkerpop.gremlin.structure.io.binary.types.BulkSetSerializer;
 import org.apache.tinkerpop.gremlin.structure.io.binary.types.BinarySerializer;
 import org.apache.tinkerpop.gremlin.structure.io.binary.types.CharSerializer;
-import org.apache.tinkerpop.gremlin.structure.io.binary.types.CustomTypeSerializer;
 import org.apache.tinkerpop.gremlin.structure.io.binary.types.DurationSerializer;
 import org.apache.tinkerpop.gremlin.structure.io.binary.types.EdgeSerializer;
 import org.apache.tinkerpop.gremlin.structure.io.binary.types.EnumSerializer;
@@ -48,6 +48,7 @@ import org.apache.tinkerpop.gremlin.structure.io.binary.types.MapSerializer;
 import org.apache.tinkerpop.gremlin.structure.io.binary.types.DateTimeSerializer;
 import org.apache.tinkerpop.gremlin.structure.io.binary.types.PathSerializer;
 import org.apache.tinkerpop.gremlin.structure.io.binary.types.PropertySerializer;
+import org.apache.tinkerpop.gremlin.structure.io.binary.types.ProviderDefinedTypeSerializer;
 import org.apache.tinkerpop.gremlin.structure.io.binary.types.SetSerializer;
 import org.apache.tinkerpop.gremlin.structure.io.binary.types.SingleTypeSerializer;
 import org.apache.tinkerpop.gremlin.structure.io.binary.types.StringSerializer;
@@ -57,7 +58,6 @@ import org.apache.tinkerpop.gremlin.structure.io.binary.types.TreeSerializer;
 import org.apache.tinkerpop.gremlin.structure.io.binary.types.UUIDSerializer;
 import org.apache.tinkerpop.gremlin.structure.io.binary.types.VertexPropertySerializer;
 import org.apache.tinkerpop.gremlin.structure.io.binary.types.VertexSerializer;
-import org.javatuples.Pair;
 
 import java.io.IOException;
 import java.lang.reflect.Modifier;
@@ -120,7 +120,8 @@ public class TypeSerializerRegistry {
 
             new RegistryEntry<>(Character.class, new CharSerializer()),
             new RegistryEntry<>(Duration.class, new DurationSerializer()),
-            new RegistryEntry<>(OffsetDateTime.class, new DateTimeSerializer())
+            new RegistryEntry<>(OffsetDateTime.class, new DateTimeSerializer()),
+            new RegistryEntry<>(ProviderDefinedType.class, new ProviderDefinedTypeSerializer())
     };
 
     public static final TypeSerializerRegistry INSTANCE = build().create();
@@ -140,39 +141,10 @@ public class TypeSerializerRegistry {
          * </p>
          */
         public <DT> Builder add(final Class<DT> type, final TypeSerializer<DT> serializer) {
-            if (serializer.getDataType() == DataType.CUSTOM) {
-                throw new IllegalArgumentException("DataType can not be CUSTOM, use addCustomType() method instead");
-            }
-
             if (serializer.getDataType() == DataType.UNSPECIFIED_NULL) {
                 throw new IllegalArgumentException("Adding a serializer for a UNSPECIFIED_NULL is not permitted");
             }
 
-            if (serializer instanceof CustomTypeSerializer) {
-                throw new IllegalArgumentException(
-                        "CustomTypeSerializer implementations are reserved for customtypes");
-            }
-
-            list.add(new RegistryEntry<>(type, serializer));
-            return this;
-        }
-
-        /**
-         * Adds a serializer for a custom type.
-         */
-        public <DT> Builder addCustomType(final Class<DT> type, final CustomTypeSerializer<DT> serializer) {
-            if (serializer == null) {
-                throw new NullPointerException("serializer can not be null");
-            }
-
-            if (serializer.getDataType() != DataType.CUSTOM) {
-                throw new IllegalArgumentException("Custom serializer must use CUSTOM data type");
-            }
-
-            if (serializer.getTypeName() == null) {
-                throw new NullPointerException("serializer custom type name can not be null");
-            }
-
             list.add(new RegistryEntry<>(type, serializer));
             return this;
         }
@@ -185,21 +157,6 @@ public class TypeSerializerRegistry {
             return this;
         }
 
-        /**
-         * Add {@link CustomTypeSerializer} by way of an {@link IoRegistry}. The registry entries should be bound to
-         * {@link GraphBinaryIo}.
-         */
-        public Builder addRegistry(final IoRegistry registry) {
-            if (null == registry) throw new IllegalArgumentException("The registry cannot be null");
-
-            final List<Pair<Class, CustomTypeSerializer>> classSerializers = registry.find(GraphBinaryIo.class, CustomTypeSerializer.class);
-            for (Pair<Class,CustomTypeSerializer> cs : classSerializers) {
-                addCustomType(cs.getValue0(), cs.getValue1());
-            }
-
-            return this;
-        }
-
         /**
          * Creates a new {@link TypeSerializerRegistry} instance based on the serializers added.
          */
@@ -225,15 +182,6 @@ public class TypeSerializerRegistry {
             return typeSerializer.getDataType();
         }
 
-        public String getCustomTypeName() {
-            if (getDataType() != DataType.CUSTOM) {
-                return null;
-            }
-
-            final CustomTypeSerializer customTypeSerializer = (CustomTypeSerializer) typeSerializer;
-            return customTypeSerializer.getTypeName();
-        }
-
         public TypeSerializer<DT> getTypeSerializer() {
             return typeSerializer;
         }
@@ -242,7 +190,6 @@ public class TypeSerializerRegistry {
     private final Map<Class<?>, TypeSerializer<?>> serializers = new HashMap<>();
     private final Map<Class<?>, TypeSerializer<?>> serializersByInterface = new LinkedHashMap<>();
     private final Map<DataType, TypeSerializer<?>> serializersByDataType = new HashMap<>();
-    private final Map<String, CustomTypeSerializer> serializersByCustomTypeName = new HashMap<>();
     private Function<Class<?>, TypeSerializer<?>> fallbackResolver;
 
     /**
@@ -291,9 +238,7 @@ public class TypeSerializerRegistry {
             serializersByInterface.put(type, serializer);
         }
 
-        if (serializer.getDataType() == DataType.CUSTOM) {
-            serializersByCustomTypeName.put(entry.getCustomTypeName(), (CustomTypeSerializer) serializer);
-        } else if (serializer.getDataType() != null) {
+        if (serializer.getDataType() != null) {
             serializersByDataType.put(serializer.getDataType(), serializer);
         }
     }
@@ -333,7 +278,15 @@ public class TypeSerializerRegistry {
             serializer = fallbackResolver.apply(type);
         }
 
-        validateInstance(serializer, type.getTypeName());
+        if (null == serializer && type.isAnnotationPresent(ProviderDefined.class)) {
+            serializer = serializersByDataType.get(DataType.COMPOSITE_PDT);
+        }
+
+        if (serializer == null) {
+            throw new IOException(String.format(
+                    "Serializer not found for type %s. If this is a provider-defined type, annotate the class with @ProviderDefined.",
+                    type.getTypeName()));
+        }
 
         // Store the lookup match to avoid looking it up in the future
         serializersByImplementation.put(type, serializer);
@@ -342,26 +295,9 @@ public class TypeSerializerRegistry {
     }
 
     public <DT> TypeSerializer<DT> getSerializer(final DataType dataType) throws IOException {
-        if (dataType == DataType.CUSTOM) {
-            throw new IllegalArgumentException("Custom type serializers can not be retrieved using this method");
-        }
-
         return validateInstance(serializersByDataType.get(dataType), dataType.toString());
     }
 
-    /**
-     * Gets the serializer for a given custom type name.
-     */
-    public <DT> CustomTypeSerializer<DT> getSerializerForCustomType(final String name) throws IOException {
-        final CustomTypeSerializer serializer = serializersByCustomTypeName.get(name);
-
-        if (serializer == null) {
-            throw new IOException(String.format("Serializer for custom type '%s' not found", name));
-        }
-
-        return serializer;
-    }
-
     private static TypeSerializer validateInstance(final TypeSerializer serializer, final String typeName) throws IOException {
         if (serializer == null) {
             throw new IOException(String.format("Serializer for type %s not found", typeName));
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/types/ProviderDefinedTypeSerializer.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/types/ProviderDefinedTypeSerializer.java
new file mode 100644
index 0000000000..a833d2d46a
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/types/ProviderDefinedTypeSerializer.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.io.binary.types;
+
+import org.apache.tinkerpop.gremlin.structure.io.Buffer;
+import org.apache.tinkerpop.gremlin.structure.io.binary.DataType;
+import org.apache.tinkerpop.gremlin.structure.io.binary.GraphBinaryReader;
+import org.apache.tinkerpop.gremlin.structure.io.binary.GraphBinaryWriter;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType;
+
+import java.io.IOException;
+import java.util.Map;
+
+public class ProviderDefinedTypeSerializer extends SimpleTypeSerializer<ProviderDefinedType> {
+
+    public ProviderDefinedTypeSerializer() {
+        super(DataType.COMPOSITE_PDT);
+    }
+
+    @Override
+    protected ProviderDefinedType readValue(final Buffer buffer, final GraphBinaryReader context) throws IOException {
+        final String name = context.read(buffer);
+        if (name == null || name.isEmpty())
+            throw new IOException("ProviderDefinedType name cannot be null or empty");
+        final Map<?, ?> fields = context.read(buffer);
+        for (final Object key : fields.keySet()) {
+            if (!(key instanceof String))
+                throw new IOException("ProviderDefinedType fields map must have String keys, found: " + key.getClass().getName());
+        }
+        @SuppressWarnings("unchecked")
+        final Map<String, Object> typedFields = (Map<String, Object>) (Map<?, ?>) fields;
+        return new ProviderDefinedType(name, typedFields);
+    }
+
+    @Override
+    protected void writeValue(final ProviderDefinedType value, final Buffer buffer, final GraphBinaryWriter context) throws IOException {
+        context.write(value.getName(), buffer);
+        context.write(value.getFields(), buffer);
+    }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONMapper.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONMapper.java
index 3da5c4e367..6877b67c69 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONMapper.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONMapper.java
@@ -21,6 +21,7 @@ package org.apache.tinkerpop.gremlin.structure.io.graphson;
 import org.apache.tinkerpop.gremlin.structure.Graph;
 import org.apache.tinkerpop.gremlin.structure.io.IoRegistry;
 import org.apache.tinkerpop.gremlin.structure.io.Mapper;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeRegistry;
 import org.apache.tinkerpop.shaded.jackson.annotation.JsonTypeInfo;
 import org.apache.tinkerpop.shaded.jackson.core.JsonFactory;
 import org.apache.tinkerpop.shaded.jackson.core.JsonGenerator;
@@ -71,6 +72,7 @@ public class GraphSONMapper implements Mapper<ObjectMapper> {
     private final GraphSONVersion version;
     private final TypeInfo typeInfo;
     private final StreamReadConstraints streamReadConstraints;
+    private final ProviderDefinedTypeRegistry pdtRegistry;
 
     private GraphSONMapper(final Builder builder) {
         this.customModules = builder.customModules;
@@ -79,6 +81,7 @@ public class GraphSONMapper implements Mapper<ObjectMapper> {
         this.version = builder.version;
         this.streamReadConstraints = builder.streamReadConstraintsBuilder.build();
         this.typeInfo = builder.typeInfo;
+        this.pdtRegistry = builder.pdtRegistry;
     }
 
     @Override
@@ -89,6 +92,9 @@ public class GraphSONMapper implements Mapper<ObjectMapper> {
         }
 
         final GraphSONModule graphSONModule = version.getBuilder().create(normalize, typeInfo);
+        if (pdtRegistry != null && graphSONModule instanceof GraphSONModule.GraphSONModuleV4) {
+            ((GraphSONModule.GraphSONModuleV4) graphSONModule).setPdtRegistry(pdtRegistry);
+        }
         om.registerModule(graphSONModule);
         customModules.forEach(om::registerModule);
 
@@ -186,6 +192,7 @@ public class GraphSONMapper implements Mapper<ObjectMapper> {
         builder.loadCustomModules = mapper.loadCustomSerializers;
         builder.normalize = mapper.normalize;
         builder.typeInfo = mapper.typeInfo;
+        builder.pdtRegistry = mapper.pdtRegistry;
         builder.streamReadConstraintsBuilder = mapper.streamReadConstraints.rebuild();
 
         return builder;
@@ -217,6 +224,7 @@ public class GraphSONMapper implements Mapper<ObjectMapper> {
         private StreamReadConstraints.Builder streamReadConstraintsBuilder = StreamReadConstraints.builder()
                 .maxNumberLength(DEFAULT_MAX_NUMBER_LENGTH);
         private TypeInfo typeInfo = null;
+        private ProviderDefinedTypeRegistry pdtRegistry = null;
 
         private Builder() {
         }
@@ -301,6 +309,15 @@ public class GraphSONMapper implements Mapper<ObjectMapper> {
             return this;
         }
 
+        /**
+         * Set the {@link ProviderDefinedTypeRegistry} to enable automatic hydration of
+         * {@link org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType} values during deserialization.
+         */
+        public Builder pdtRegistry(final ProviderDefinedTypeRegistry pdtRegistry) {
+            this.pdtRegistry = pdtRegistry;
+            return this;
+        }
+
         public Builder maxNumberLength(final int maxNumLength) {
             this.streamReadConstraintsBuilder.maxNumberLength(maxNumLength);
             return this;
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONModule.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONModule.java
index 6e98642d7b..5cdb5cb1c3 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONModule.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONModule.java
@@ -81,6 +81,8 @@ import org.apache.tinkerpop.gremlin.structure.Property;
 import org.apache.tinkerpop.gremlin.structure.T;
 import org.apache.tinkerpop.gremlin.structure.Vertex;
 import org.apache.tinkerpop.gremlin.structure.VertexProperty;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeRegistry;
 import org.apache.tinkerpop.gremlin.structure.util.star.DirectionalStarGraph;
 import org.apache.tinkerpop.gremlin.structure.util.star.StarGraphGraphSONSerializerV1;
 import org.apache.tinkerpop.gremlin.structure.util.star.StarGraphGraphSONSerializerV2;
@@ -156,12 +158,15 @@ abstract class GraphSONModule extends TinkerPopJacksonModule {
                     put(Path.class, "Path");
                     put(VertexProperty.class, "VertexProperty");
                     put(Tree.class, "Tree");
+                    put(ProviderDefinedType.class, "CompositePdt");
                     Stream.of(
                             Direction.class,
                             Merge.class,
                             T.class).forEach(e -> put(e, e.getSimpleName()));
                 }});
 
+        private final PdtGraphSONSerializersV4.ProviderDefinedTypeJacksonDeserializer pdtDeserializer;
+
         /**
          * Constructs a new object.
          */
@@ -178,6 +183,7 @@ abstract class GraphSONModule extends TinkerPopJacksonModule {
             addSerializer(Path.class, new GraphSONSerializersV4.PathJacksonSerializer());
             addSerializer(DirectionalStarGraph.class, new StarGraphGraphSONSerializerV4(normalize));
             addSerializer(Tree.class, new GraphSONSerializersV4.TreeJacksonSerializer());
+            addSerializer(ProviderDefinedType.class, new PdtGraphSONSerializersV4.ProviderDefinedTypeJacksonSerializer());
 
             // java.util - use the standard jackson serializers for collections when types aren't embedded
             if (typeInfo != TypeInfo.NO_TYPES) {
@@ -208,6 +214,8 @@ abstract class GraphSONModule extends TinkerPopJacksonModule {
             addDeserializer(Path.class, new GraphSONSerializersV4.PathJacksonDeserializer());
             addDeserializer(VertexProperty.class, new GraphSONSerializersV4.VertexPropertyJacksonDeserializer());
             addDeserializer(Tree.class, new GraphSONSerializersV4.TreeJacksonDeserializer());
+            pdtDeserializer = new PdtGraphSONSerializersV4.ProviderDefinedTypeJacksonDeserializer();
+            addDeserializer(ProviderDefinedType.class, pdtDeserializer);
 
             // java.util - use the standard jackson serializers for collections when types aren't embedded
             if (typeInfo != TypeInfo.NO_TYPES) {
@@ -232,6 +240,10 @@ abstract class GraphSONModule extends TinkerPopJacksonModule {
             return new Builder();
         }
 
+        void setPdtRegistry(final ProviderDefinedTypeRegistry registry) {
+            pdtDeserializer.setRegistry(registry);
+        }
+
         @Override
         public Map<Class, String> getTypeDefinitions() {
             return TYPE_DEFINITIONS;
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/PdtGraphSONSerializersV4.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/PdtGraphSONSerializersV4.java
new file mode 100644
index 0000000000..de116f54aa
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/PdtGraphSONSerializersV4.java
@@ -0,0 +1,116 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.io.graphson;
+
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeRegistry;
+import org.apache.tinkerpop.shaded.jackson.core.JsonGenerator;
+import org.apache.tinkerpop.shaded.jackson.core.JsonParser;
+import org.apache.tinkerpop.shaded.jackson.core.JsonToken;
+import org.apache.tinkerpop.shaded.jackson.databind.DeserializationContext;
+import org.apache.tinkerpop.shaded.jackson.databind.SerializerProvider;
+import org.apache.tinkerpop.shaded.jackson.databind.deser.std.StdDeserializer;
+import org.apache.tinkerpop.shaded.jackson.databind.ser.std.StdScalarSerializer;
+
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * GraphSON V4 serializers for {@link ProviderDefinedType}.
+ */
+final class PdtGraphSONSerializersV4 {
+
+    private PdtGraphSONSerializersV4() {
+    }
+
+    final static class ProviderDefinedTypeJacksonSerializer extends StdScalarSerializer<ProviderDefinedType> {
+
+        public ProviderDefinedTypeJacksonSerializer() {
+            super(ProviderDefinedType.class);
+        }
+
+        @Override
+        public void serialize(final ProviderDefinedType pdt, final JsonGenerator jsonGenerator,
+                              final SerializerProvider serializerProvider) throws IOException {
+            jsonGenerator.writeStartObject();
+            jsonGenerator.writeStringField("type", pdt.getName());
+            jsonGenerator.writeFieldName("fields");
+            jsonGenerator.writeStartObject();
+            for (final Map.Entry<String, Object> entry : pdt.getFields().entrySet()) {
+                jsonGenerator.writeFieldName(entry.getKey());
+                jsonGenerator.writeObject(entry.getValue());
+            }
+            jsonGenerator.writeEndObject();
+            jsonGenerator.writeEndObject();
+        }
+    }
+
+    static class ProviderDefinedTypeJacksonDeserializer extends StdDeserializer<ProviderDefinedType> {
+
+        private ProviderDefinedTypeRegistry registry;
+
+        public ProviderDefinedTypeJacksonDeserializer() {
+            super(ProviderDefinedType.class);
+        }
+
+        void setRegistry(final ProviderDefinedTypeRegistry registry) {
+            this.registry = registry;
+        }
+
+        @Override
+        public ProviderDefinedType deserialize(final JsonParser jsonParser, final DeserializationContext deserializationContext)
+                throws IOException {
+            String typeName = null;
+            Map<String, Object> fields = new LinkedHashMap<>();
+
+            while (jsonParser.nextToken() != JsonToken.END_OBJECT) {
+                final String fieldName = jsonParser.getCurrentName();
+                if ("type".equals(fieldName)) {
+                    jsonParser.nextToken();
+                    typeName = jsonParser.getText();
+                } else if ("fields".equals(fieldName)) {
+                    jsonParser.nextToken();
+                    while (jsonParser.nextToken() != JsonToken.END_OBJECT) {
+                        final String key = jsonParser.getCurrentName();
+                        jsonParser.nextToken();
+                        final Object value = deserializationContext.readValue(jsonParser, Object.class);
+                        fields.put(key, value);
+                    }
+                }
+            }
+
+            final ProviderDefinedType pdt = new ProviderDefinedType(typeName, fields);
+            if (registry != null) {
+                final Object hydrated = registry.hydrate(pdt);
+                if (hydrated instanceof ProviderDefinedType)
+                    return (ProviderDefinedType) hydrated;
+                // Store hydrated object back as a single-entry PDT so the typed result is accessible.
+                // This preserves the return type contract while enabling hydration.
+                return pdt.withHydrated(hydrated);
+            }
+            return pdt;
+        }
+
+        @Override
+        public boolean isCachable() {
+            return true;
+        }
+    }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/types/CustomTypeSerializer.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefined.java
similarity index 58%
copy from gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/types/CustomTypeSerializer.java
copy to gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefined.java
index a54580cab0..5161192376 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/types/CustomTypeSerializer.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefined.java
@@ -16,17 +16,22 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.tinkerpop.gremlin.structure.io.binary.types;
+package org.apache.tinkerpop.gremlin.structure.io.pdt;
 
-import org.apache.tinkerpop.gremlin.structure.io.binary.TypeSerializer;
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
 
 /**
- * Represents a serializer for a custom (provider specific) serializer.
- * @param <T>
+ * Marks a class as a provider-defined type for serialization via {@link ProviderDefinedType}.
  */
-public interface CustomTypeSerializer<T> extends TypeSerializer<T> {
-    /**
-     * Gets the custom type name.
-     */
-    String getTypeName();
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+public @interface ProviderDefined {
+    String name() default "";
+    String[] includedFields() default {};
+    String[] excludedFields() default {};
 }
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedType.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedType.java
new file mode 100644
index 0000000000..165a32967e
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedType.java
@@ -0,0 +1,186 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.io.pdt;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * An immutable representation of a provider-defined type consisting of a name and a map of fields.
+ */
+public final class ProviderDefinedType {
+
+    private static final ConcurrentHashMap<Class<?>, FieldCache> FIELD_CACHE = new ConcurrentHashMap<>();
+
+    private final String name;
+    private final Map<String, Object> fields;
+    private Object hydrated;
+
+    public ProviderDefinedType(final String name, final Map<String, Object> fields) {
+        if (name == null || name.isEmpty())
+            throw new IllegalArgumentException("name cannot be null or empty");
+        if (fields == null)
+            throw new IllegalArgumentException("fields cannot be null");
+        this.name = name;
+        this.fields = Collections.unmodifiableMap(new LinkedHashMap<>(fields));
+    }
+
+    /**
+     * Creates a {@code ProviderDefinedType} from an object annotated with {@link ProviderDefined}.
+     */
+    public static ProviderDefinedType from(final Object obj) {
+        if (obj == null)
+            throw new IllegalArgumentException("obj cannot be null");
+
+        final Class<?> clazz = obj.getClass();
+        final FieldCache cache = FIELD_CACHE.computeIfAbsent(clazz, ProviderDefinedType::buildCache);
+
+        final Map<String, Object> fields = new LinkedHashMap<>();
+        for (final Field field : cache.fields) {
+            try {
+                fields.put(field.getName(), field.get(obj));
+            } catch (Exception e) {
+                throw new RuntimeException("Failed to read field '" + field.getName() + "' from " + clazz.getName(), e);
+            }
+        }
+
+        return new ProviderDefinedType(cache.name, fields);
+    }
+
+    /**
+     * Package-private access to the resolved type name for a {@link ProviderDefined}-annotated class.
+     * Validates the annotation and field configuration via the shared field cache.
+     */
+    static String resolveTypeName(final Class<?> clazz) {
+        return FIELD_CACHE.computeIfAbsent(clazz, ProviderDefinedType::buildCache).name;
+    }
+
+    /**
+     * Package-private access to the resolved serializable fields for a {@link ProviderDefined}-annotated class.
+     */
+    static Field[] resolveFields(final Class<?> clazz) {
+        return FIELD_CACHE.computeIfAbsent(clazz, ProviderDefinedType::buildCache).fields;
+    }
+
+    private static FieldCache buildCache(final Class<?> clazz) {
+        final ProviderDefined annotation = clazz.getAnnotation(ProviderDefined.class);
+        if (annotation == null)
+            throw new IllegalArgumentException(clazz.getName() + " is not annotated with @ProviderDefined");
+
+        final String typeName = annotation.name().isEmpty() ? clazz.getSimpleName() : annotation.name();
+        final String[] included = annotation.includedFields();
+        final String[] excluded = annotation.excludedFields();
+
+        if (included.length > 0 && excluded.length > 0)
+            throw new IllegalArgumentException("@ProviderDefined cannot specify both includedFields and excludedFields");
+
+        final Set<String> includedSet = included.length > 0 ? new HashSet<>(Arrays.asList(included)) : null;
+        final Set<String> excludedSet = excluded.length > 0 ? new HashSet<>(Arrays.asList(excluded)) : Collections.emptySet();
+
+        final Field[] allFields = getAllFields(clazz).toArray(new Field[0]);
+        final Field[] filtered = Arrays.stream(allFields)
+                .filter(f -> !f.isSynthetic())
+                .filter(f -> {
+                    if (includedSet != null) return includedSet.contains(f.getName());
+                    return !excludedSet.contains(f.getName());
+                })
+                .peek(f -> f.setAccessible(true))
+                .toArray(Field[]::new);
+
+        return new FieldCache(typeName, filtered);
+    }
+
+    private static List<Field> getAllFields(final Class<?> clazz) {
+        final List<Field> fields = new ArrayList<>();
+        Class<?> current = clazz;
+        while (current != null && current != Object.class) {
+            fields.addAll(Arrays.asList(current.getDeclaredFields()));
+            current = current.getSuperclass();
+        }
+        return fields;
+    }
+
+    private static class FieldCache {
+        final String name;
+        final Field[] fields;
+
+        FieldCache(final String name, final Field[] fields) {
+            this.name = name;
+            this.fields = fields;
+        }
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public Map<String, Object> getFields() {
+        return fields;
+    }
+
+    /**
+     * Returns a copy of this PDT with the hydrated object attached.
+     */
+    public ProviderDefinedType withHydrated(final Object hydrated) {
+        final ProviderDefinedType copy = new ProviderDefinedType(this.name, this.fields);
+        copy.hydrated = hydrated;
+        return copy;
+    }
+
+    /**
+     * Returns the hydrated object if this PDT was hydrated by a {@link ProviderDefinedTypeRegistry}, or {@code null}.
+     */
+    public Object getHydrated() {
+        return hydrated;
+    }
+
+    /**
+     * Equality is based solely on {@code name} and {@code fields} (the serialized wire form).
+     * The {@code hydrated} field is intentionally excluded — it is a transient, derived view
+     * cached by the deserializer via {@link #withHydrated(Object)} and is not part of the
+     * type's logical identity.
+     */
+    @Override
+    public boolean equals(final Object o) {
+        if (this == o) return true;
+        if (!(o instanceof ProviderDefinedType)) return false;
+        final ProviderDefinedType that = (ProviderDefinedType) o;
+        return name.equals(that.name) && fields.equals(that.fields);
+    }
+
+    /** See {@link #equals(Object)} for rationale on field inclusion. */
+    @Override
+    public int hashCode() {
+        return Objects.hash(name, fields);
+    }
+
+    @Override
+    public String toString() {
+        return "pdt[" + name + "]" + fields;
+    }
+}
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/types/CustomTypeSerializer.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedTypeAdapter.java
similarity index 69%
copy from gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/types/CustomTypeSerializer.java
copy to gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedTypeAdapter.java
index a54580cab0..701fba0d49 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/types/CustomTypeSerializer.java
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedTypeAdapter.java
@@ -16,17 +16,16 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.tinkerpop.gremlin.structure.io.binary.types;
+package org.apache.tinkerpop.gremlin.structure.io.pdt;
 
-import org.apache.tinkerpop.gremlin.structure.io.binary.TypeSerializer;
+import java.util.Map;
 
 /**
- * Represents a serializer for a custom (provider specific) serializer.
- * @param <T>
+ * Adapter for converting between a typed object and a {@link ProviderDefinedType} field map.
  */
-public interface CustomTypeSerializer<T> extends TypeSerializer<T> {
-    /**
-     * Gets the custom type name.
-     */
-    String getTypeName();
+public interface ProviderDefinedTypeAdapter<T> {
+    String typeName();
+    Class<T> targetClass();
+    Map<String, Object> toFields(T obj);
+    T fromFields(Map<String, Object> fields);
 }
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedTypeRegistry.java b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedTypeRegistry.java
new file mode 100644
index 0000000000..dec03e5383
--- /dev/null
+++ b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedTypeRegistry.java
@@ -0,0 +1,224 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.io.pdt;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.ServiceLoader;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Registry for {@link ProviderDefinedTypeAdapter} instances that supports hydration of
+ * {@link ProviderDefinedType} values into typed objects.
+ */
+public final class ProviderDefinedTypeRegistry {
+
+    private static final Logger logger = LoggerFactory.getLogger(ProviderDefinedTypeRegistry.class);
+
+    private final Map<String, ProviderDefinedTypeAdapter<?>> adaptersByName = new ConcurrentHashMap<>();
+    private final Map<Class<?>, ProviderDefinedTypeAdapter<?>> adaptersByClass = new ConcurrentHashMap<>();
+
+    private ProviderDefinedTypeRegistry() {}
+
+    /**
+     * Creates a registry populated via {@link ServiceLoader} discovery.
+     */
+    @SuppressWarnings("rawtypes")
+    public static ProviderDefinedTypeRegistry create() {
+        final ProviderDefinedTypeRegistry registry = new ProviderDefinedTypeRegistry();
+        for (final ProviderDefinedTypeAdapter adapter : ServiceLoader.load(ProviderDefinedTypeAdapter.class)) {
+            registry.register(adapter);
+        }
+        return registry;
+    }
+
+    /**
+     * Creates an empty registry for manual registration.
+     */
+    public static ProviderDefinedTypeRegistry empty() {
+        return new ProviderDefinedTypeRegistry();
+    }
+
+    public void register(final ProviderDefinedTypeAdapter<?> adapter) {
+        adaptersByName.put(adapter.typeName(), adapter);
+        adaptersByClass.put(adapter.targetClass(), adapter);
+    }
+
+    /**
+     * Registers one or more classes annotated with {@link ProviderDefined} for automatic round-trip hydration.
+     * An adapter is synthesized from the annotation metadata using reflection.
+     *
+     * @throws IllegalArgumentException if any class is not annotated with {@link ProviderDefined}
+     */
+    public void register(final Class<?>... annotatedClasses) {
+        for (final Class<?> clazz : annotatedClasses) {
+            register(AnnotatedTypeAdapter.of(clazz));
+        }
+    }
+
+    public Optional<ProviderDefinedTypeAdapter<?>> getAdapterByName(final String name) {
+        return Optional.ofNullable(adaptersByName.get(name));
+    }
+
+    public Optional<ProviderDefinedTypeAdapter<?>> getAdapterByClass(final Class<?> clazz) {
+        return Optional.ofNullable(adaptersByClass.get(clazz));
+    }
+
+    /**
+    /**
+     * Attempts to hydrate a {@link ProviderDefinedType} into a typed object using a registered adapter.
+     * Recursively hydrates nested PDT values in the fields map (including those inside Lists, Sets,
+     * and Maps) regardless of whether the outer type itself has a registered adapter — so a registered
+     * inner type is hydrated even when nested inside an unregistered outer PDT.
+     * Returns the original PDT (with nested values hydrated) if no adapter is found for the outer type,
+     * or if the adapter throws an exception.
+     */
+    @SuppressWarnings({"unchecked", "rawtypes"})
+    public Object hydrate(final ProviderDefinedType pdt) {
+        // recursively hydrate nested PDTs in the fields map, whether or not the outer has an adapter
+        boolean nestedChanged = false;
+        final Map<String, Object> hydrated = new LinkedHashMap<>();
+        for (final Map.Entry<String, Object> entry : pdt.getFields().entrySet()) {
+            final Object original = entry.getValue();
+            final Object value = hydrateValue(original);
+            if (value != original) nestedChanged = true;
+            hydrated.put(entry.getKey(), value);
+        }
+
+        final ProviderDefinedTypeAdapter adapter = adaptersByName.get(pdt.getName());
+        if (adapter == null) {
+            // No adapter for the outer type: return it raw, but with any registered nested types hydrated.
+            // Preserve identity when nothing nested was hydrated.
+            return nestedChanged ? new ProviderDefinedType(pdt.getName(), hydrated) : pdt;
+        }
+
+        try {
+            return adapter.fromFields(hydrated);
+        } catch (final Exception e) {
+            logger.warn("Failed to hydrate ProviderDefinedType '{}', returning raw PDT: {}",
+                    pdt.getName(), e.getMessage());
+            return pdt;
+        }
+    }
+
+    @SuppressWarnings({"unchecked", "rawtypes"})
+    private Object hydrateValue(final Object value) {
+        if (value instanceof ProviderDefinedType)
+            return hydrate((ProviderDefinedType) value);
+        if (value instanceof List) {
+            final List<Object> result = new ArrayList<>();
+            for (final Object item : (List<?>) value)
+                result.add(hydrateValue(item));
+            return result;
+        }
+        if (value instanceof Set) {
+            final Set<Object> result = new LinkedHashSet<>();
+            for (final Object item : (Set<?>) value)
+                result.add(hydrateValue(item));
+            return result;
+        }
+        if (value instanceof Map) {
+            final Map<Object, Object> result = new LinkedHashMap<>();
+            for (final Map.Entry<?, ?> entry : ((Map<?, ?>) value).entrySet())
+                result.put(entry.getKey(), hydrateValue(entry.getValue()));
+            return result;
+        }
+        return value;
+    }
+
+    /**
+     * A reflective adapter synthesized from a {@link ProviderDefined}-annotated class.
+     */
+    @SuppressWarnings({"unchecked", "rawtypes"})
+    private static final class AnnotatedTypeAdapter<T> implements ProviderDefinedTypeAdapter<T> {
+        private final String typeName;
+        private final Class<T> targetClass;
+        private final Field[] fields;
+
+        private AnnotatedTypeAdapter(final String typeName, final Class<T> targetClass, final Field[] fields) {
+            this.typeName = typeName;
+            this.targetClass = targetClass;
+            this.fields = fields;
+        }
+
+        static <T> AnnotatedTypeAdapter<T> of(final Class<T> clazz) {
+            if (!clazz.isAnnotationPresent(ProviderDefined.class))
+                throw new IllegalArgumentException(clazz.getName() + " is not annotated with @ProviderDefined");
+            try {
+                clazz.getDeclaredConstructor();
+            } catch (final NoSuchMethodException e) {
+                throw new IllegalArgumentException(clazz.getName() +
+                        " must have a no-arg constructor for annotation-based hydration");
+            }
+            // reuse ProviderDefinedType's validated, cached field/name resolution
+            return new AnnotatedTypeAdapter<>(
+                    ProviderDefinedType.resolveTypeName(clazz),
+                    clazz,
+                    ProviderDefinedType.resolveFields(clazz));
+        }
+
+        @Override public String typeName() { return typeName; }
+        @Override public Class<T> targetClass() { return targetClass; }
+
+        @Override
+        public Map<String, Object> toFields(final T obj) {
+            return ProviderDefinedType.from(obj).getFields();
+        }
+
+        @Override
+        public T fromFields(final Map<String, Object> fieldMap) {
+            try {
+                final java.lang.reflect.Constructor<T> ctor = targetClass.getDeclaredConstructor();
+                ctor.setAccessible(true);
+                final T obj = ctor.newInstance();
+                for (final Field field : fields) {
+                    final Object value = fieldMap.get(field.getName());
+                    if (value != null)
+                        field.set(obj, coerce(value, field.getType()));
+                }
+                return obj;
+            } catch (final ReflectiveOperationException e) {
+                throw new RuntimeException("Failed to hydrate " + targetClass.getName() + ": " + e, e);
+            }
+        }
+
+        private static Object coerce(final Object value, final Class<?> targetType) {
+            if (targetType.isInstance(value)) return value;
+            if (value instanceof Number) {
+                final Number n = (Number) value;
+                if (targetType == int.class || targetType == Integer.class) return n.intValue();
+                if (targetType == long.class || targetType == Long.class) return n.longValue();
+                if (targetType == double.class || targetType == Double.class) return n.doubleValue();
+                if (targetType == float.class || targetType == Float.class) return n.floatValue();
+                if (targetType == short.class || targetType == Short.class) return n.shortValue();
+                if (targetType == byte.class || targetType == Byte.class) return n.byteValue();
+            }
+            return value;
+        }
+    }
+}
diff --git a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/language/grammar/GeneralLiteralVisitorTest.java b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/language/grammar/GeneralLiteralVisitorTest.java
index 71f4acce19..3adc7b63bb 100644
--- a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/language/grammar/GeneralLiteralVisitorTest.java
+++ b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/language/grammar/GeneralLiteralVisitorTest.java
@@ -24,6 +24,7 @@ import org.apache.tinkerpop.gremlin.process.traversal.GType;
 import org.apache.tinkerpop.gremlin.structure.Direction;
 import org.apache.tinkerpop.gremlin.structure.T;
 import org.apache.tinkerpop.gremlin.structure.VertexProperty;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType;
 import org.hamcrest.Matchers;
 import org.junit.Assert;
 import org.junit.Test;
@@ -1049,4 +1050,44 @@ public class GeneralLiteralVisitorTest {
             }
         }
     }
+
+    public static class ValidPdtLiteralTest {
+        @Test
+        public void shouldParsePdtLiteral() {
+            final GremlinLexer lexer = new GremlinLexer(CharStreams.fromString("PDT(\"MyType\",[\"x\":1,\"y\":\"hello\"])"));
+            final GremlinParser parser = new GremlinParser(new CommonTokenStream(lexer));
+            final GremlinParser.PdtLiteralContext ctx = parser.pdtLiteral();
+            final Object result = new GenericLiteralVisitor(new GremlinAntlrToJava()).visitPdtLiteral(ctx);
+            assertThat(result, instanceOf(ProviderDefinedType.class));
+            final ProviderDefinedType pdt = (ProviderDefinedType) result;
+            assertEquals("MyType", pdt.getName());
+            assertEquals(1, pdt.getFields().get("x"));
+            assertEquals("hello", pdt.getFields().get("y"));
+        }
+
+        @Test
+        public void shouldParsePdtLiteralWithEmptyMap() {
+            final GremlinLexer lexer = new GremlinLexer(CharStreams.fromString("PDT(\"Empty\",[:])"));
+            final GremlinParser parser = new GremlinParser(new CommonTokenStream(lexer));
+            final GremlinParser.PdtLiteralContext ctx = parser.pdtLiteral();
+            final Object result = new GenericLiteralVisitor(new GremlinAntlrToJava()).visitPdtLiteral(ctx);
+            assertThat(result, instanceOf(ProviderDefinedType.class));
+            final ProviderDefinedType pdt = (ProviderDefinedType) result;
+            assertEquals("Empty", pdt.getName());
+            assertTrue(pdt.getFields().isEmpty());
+        }
+
+        @Test
+        public void shouldRejectNonStringMapKey() {
+            final GremlinLexer lexer = new GremlinLexer(CharStreams.fromString("PDT(\"Bad\",[1:\"value\"])"));
+            final GremlinParser parser = new GremlinParser(new CommonTokenStream(lexer));
+            final GremlinParser.PdtLiteralContext ctx = parser.pdtLiteral();
+            try {
+                new GenericLiteralVisitor(new GremlinAntlrToJava()).visitPdtLiteral(ctx);
+                fail("Expected IllegalArgumentException for non-String map key");
+            } catch (final IllegalArgumentException e) {
+                assertTrue(e.getMessage().contains("PDT fields map must have String keys, found: java.lang.Integer"));
+            }
+        }
+    }
 }
diff --git a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/language/translator/GremlinTranslatorTest.java b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/language/translator/GremlinTranslatorTest.java
index 8e13823450..43c785a845 100644
--- a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/language/translator/GremlinTranslatorTest.java
+++ b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/language/translator/GremlinTranslatorTest.java
@@ -1480,6 +1480,15 @@ public class GremlinTranslatorTest {
                             "g.inject(ByteBuffer.wrap(Base64.getDecoder().decode(\"AQID\")))",
                             "g.inject(Buffer.from(\"AQID\",'base64'))",
                             "g.inject(base64.b64decode('AQID'))"},
+                    {"g.inject(PDT(\"Point\",[\"x\":1,\"y\":2]))",
+                            null,
+                            "g.inject(providerdefinedtype0)",
+                            "g.Inject<object>(new ProviderDefinedType(\"Point\", new Dictionary<object, object> {{ \"x\", 1 }, { \"y\", 2 }}))",
+                            "g.Inject(&gremlingo.ProviderDefinedType{Name: \"Point\", Fields: map[interface{}]interface{}{\"x\": 1, \"y\": 2 }})",
+                            "g.inject(new ProviderDefinedType(\"Point\", [\"x\":1, \"y\":2]))",
+                            "g.inject(new ProviderDefinedType(\"Point\", new LinkedHashMap<Object, Object>() {{ put(\"x\", 1); put(\"y\", 2); }}))",
+                            "g.inject(new ProviderDefinedType(\"Point\", new Map([[\"x\", 1], [\"y\", 2]])))",
+                            "g.inject(ProviderDefinedType('Point', { 'x': 1, 'y': 2 }))"},
             });
         }
 
diff --git a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/GremlinLangTest.java b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/GremlinLangTest.java
index 57649b697d..3a62dcd9ec 100644
--- a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/GremlinLangTest.java
+++ b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/process/traversal/GremlinLangTest.java
@@ -28,6 +28,10 @@ import org.apache.tinkerpop.gremlin.structure.T;
 import org.apache.tinkerpop.gremlin.structure.VertexProperty;
 import org.apache.tinkerpop.gremlin.structure.util.detached.DetachedVertex;
 import org.apache.tinkerpop.gremlin.structure.util.empty.EmptyGraph;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefined;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeAdapter;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeRegistry;
 import org.apache.tinkerpop.gremlin.structure.util.reference.ReferenceEdge;
 import org.apache.tinkerpop.gremlin.structure.util.reference.ReferenceVertex;
 import org.apache.tinkerpop.gremlin.util.DatetimeHelper;
@@ -140,6 +144,19 @@ public class GremlinLangTest {
                 {g.inject(new byte[]{1, 2, 3}), "g.inject(Binary(\"AQID\"))"},
                 {g.inject(new byte[]{}), "g.inject(Binary(\"\"))"},
                 {g.inject(new byte[]{0}), "g.inject(Binary(\"AA==\"))"},
+                // PDT
+                {g.inject(new ProviderDefinedType("MyType", asMap("x", 1, "y", "hello"))),
+                        "g.inject(PDT(\"MyType\",[\"x\":1,\"y\":\"hello\"]))"},
+                {g.inject(new ProviderDefinedType("Empty", Collections.emptyMap())),
+                        "g.inject(PDT(\"Empty\",[:]))"},
+                // PDT with special characters in name
+                {g.inject(new ProviderDefinedType("say\"hello\"", asMap("v", 1))),
+                        "g.inject(PDT(\"say\\\"hello\\\"\",[\"v\":1]))"},
+                {g.inject(new ProviderDefinedType("back\\slash", asMap("v", 1))),
+                        "g.inject(PDT(\"back\\\\slash\",[\"v\":1]))"},
+                // Nested PDT
+                {g.inject(new ProviderDefinedType("Outer", asMap("inner", new ProviderDefinedType("Inner", asMap("v", 1))))),
+                        "g.inject(PDT(\"Outer\",[\"inner\":PDT(\"Inner\",[\"v\":1])]))"},
         });
     }
 
@@ -436,6 +453,80 @@ public class GremlinLangTest {
         }
     }
 
+    public static class AdapterPrecedenceTests {
+
+        /**
+         * A type annotated with @ProviderDefined that also has an explicit adapter registered.
+         * The adapter should take precedence over the annotation.
+         */
+        @ProviderDefined(name = "AnnotationName")
+        private static class DualType {
+            public int value = 42;
+
+            private DualType() {}
+            DualType(final int value) { this.value = value; }
+        }
+
+        @Test
+        public void shouldUseAdapterOverAnnotation() {
+            final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+            registry.register(new ProviderDefinedTypeAdapter<DualType>() {
+                @Override public String typeName() { return "AdapterName"; }
+                @Override public Class<DualType> targetClass() { return DualType.class; }
+                @Override public Map<String, Object> toFields(final DualType obj) {
+                    return Collections.singletonMap("v", obj.value);
+                }
+                @Override public DualType fromFields(final Map<String, Object> fields) {
+                    return new DualType((int) fields.get("v"));
+                }
+            });
+
+            final GraphTraversalSource g2 = traversal().with(EmptyGraph.instance());
+            g2.getGremlinLang().setPdtRegistry(registry);
+            final String gremlin = g2.inject(new DualType(7)).asAdmin().getGremlinLang().getGremlin();
+
+            // adapter produces "AdapterName" with key "v", not annotation's "AnnotationName" with key "value"
+            assertTrue(gremlin, gremlin.contains("PDT(\"AdapterName\""));
+            assertTrue(gremlin, gremlin.contains("\"v\":7"));
+            assertFalse(gremlin, gremlin.contains("AnnotationName"));
+        }
+
+        private static class TestPoint {
+            final int x;
+            final int y;
+            TestPoint(int x, int y) { this.x = x; this.y = y; }
+        }
+
+        @Test
+        public void shouldDehydrateRegisteredTypeNestedInsideUnregisteredOuterPdt() {
+            final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+            registry.register(new ProviderDefinedTypeAdapter<TestPoint>() {
+                @Override public String typeName() { return "Point"; }
+                @Override public Class<TestPoint> targetClass() { return TestPoint.class; }
+                @Override public Map<String, Object> toFields(final TestPoint obj) {
+                    final Map<String, Object> m = new HashMap<>();
+                    m.put("x", obj.x);
+                    m.put("y", obj.y);
+                    return m;
+                }
+                @Override public TestPoint fromFields(final Map<String, Object> fields) {
+                    return new TestPoint((int) fields.get("x"), (int) fields.get("y"));
+                }
+            });
+
+            // Outer is a raw ProviderDefinedType whose "location" field value is a registered domain object
+            final Map<String, Object> outerFields = new LinkedHashMap<>();
+            outerFields.put("location", new TestPoint(3, 7));
+            final ProviderDefinedType outerPdt = new ProviderDefinedType("Container", outerFields);
+
+            final GraphTraversalSource g2 = traversal().with(EmptyGraph.instance());
+            g2.getGremlinLang().setPdtRegistry(registry);
+            final String gremlin = g2.inject(outerPdt).asAdmin().getGremlinLang().getGremlin();
+
+            assertEquals("g.inject(PDT(\"Container\",[\"location\":PDT(\"Point\",[\"x\":3,\"y\":7])]))", gremlin);
+        }
+    }
+
     public static class UnsupportedTypeTests {
 
         /**
diff --git a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/graphson/PdtGraphSONSerializersV4Test.java b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/graphson/PdtGraphSONSerializersV4Test.java
new file mode 100644
index 0000000000..20bf1f7238
--- /dev/null
+++ b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/graphson/PdtGraphSONSerializersV4Test.java
@@ -0,0 +1,230 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.io.graphson;
+
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeAdapter;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeRegistry;
+import org.apache.tinkerpop.shaded.jackson.databind.JsonNode;
+import org.apache.tinkerpop.shaded.jackson.databind.ObjectMapper;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Tests for {@link PdtGraphSONSerializersV4}.
+ */
+public class PdtGraphSONSerializersV4Test extends AbstractGraphSONTest {
+
+    private ObjectMapper mapper;
+    private ObjectMapper plainMapper;
+
+    @Before
+    public void setUp() {
+        mapper = GraphSONMapper.build()
+                .version(GraphSONVersion.V4_0)
+                .addCustomModule(GraphSONXModuleV4.build())
+                .typeInfo(TypeInfo.PARTIAL_TYPES)
+                .create().createMapper();
+        plainMapper = new ObjectMapper();
+    }
+
+    @Test
+    public void shouldSerializeSimplePdt() throws Exception {
+        final Map<String, Object> pdtFields = new LinkedHashMap<>();
+        pdtFields.put("x", 1);
+        pdtFields.put("y", 2);
+        final ProviderDefinedType pdt = new ProviderDefinedType("Point", pdtFields);
+
+        final String json = mapper.writeValueAsString(pdt);
+        final JsonNode node = plainMapper.readTree(json);
+
+        assertEquals("g:CompositePdt", node.get("@type").asText());
+        final JsonNode value = node.get("@value");
+        assertEquals("Point", value.get("type").asText());
+
+        final JsonNode fields = value.get("fields");
+        assertEquals("g:Int32", fields.get("x").get("@type").asText());
+        assertEquals(1, fields.get("x").get("@value").asInt());
+        assertEquals("g:Int32", fields.get("y").get("@type").asText());
+        assertEquals(2, fields.get("y").get("@value").asInt());
+    }
+
+    @Test
+    public void shouldDeserializeValidJson() throws Exception {
+        final String json = "{\"@type\":\"g:CompositePdt\",\"@value\":{\"type\":\"Point\",\"fields\":{\"x\":{\"@type\":\"g:Int32\",\"@value\":1},\"y\":{\"@type\":\"g:Int32\",\"@value\":2}}}}";
+        final ProviderDefinedType pdt = mapper.readValue(json, ProviderDefinedType.class);
+
+        assertEquals("Point", pdt.getName());
+        assertEquals(2, pdt.getFields().size());
+        assertEquals(1, pdt.getFields().get("x"));
+        assertEquals(2, pdt.getFields().get("y"));
+    }
+
+    @Test
+    public void shouldRoundTrip() throws Exception {
+        final Map<String, Object> fields = new LinkedHashMap<>();
+        fields.put("x", 1);
+        fields.put("y", 2);
+        final ProviderDefinedType original = new ProviderDefinedType("Point", fields);
+
+        final ProviderDefinedType result = serializeDeserialize(mapper, original, ProviderDefinedType.class);
+
+        assertEquals(original.getName(), result.getName());
+        assertEquals(original.getFields(), result.getFields());
+    }
+
+    @Test
+    public void shouldSerializeNestedPdt() throws Exception {
+        final Map<String, Object> innerFields = new LinkedHashMap<>();
+        innerFields.put("x", 10);
+        innerFields.put("y", 20);
+        final ProviderDefinedType inner = new ProviderDefinedType("Point", innerFields);
+
+        final Map<String, Object> outerFields = new LinkedHashMap<>();
+        outerFields.put("name", "origin");
+        outerFields.put("location", inner);
+        final ProviderDefinedType outer = new ProviderDefinedType("NamedPoint", outerFields);
+
+        final String json = mapper.writeValueAsString(outer);
+        final JsonNode node = plainMapper.readTree(json);
+
+        assertEquals("g:CompositePdt", node.get("@type").asText());
+        final JsonNode fields = node.get("@value").get("fields");
+        final JsonNode locationNode = fields.get("location");
+        assertEquals("g:CompositePdt", locationNode.get("@type").asText());
+        assertEquals("Point", locationNode.get("@value").get("type").asText());
+
+        // round-trip nested
+        final ProviderDefinedType result = serializeDeserialize(mapper, outer, ProviderDefinedType.class);
+        assertEquals("NamedPoint", result.getName());
+        assertTrue(result.getFields().get("location") instanceof ProviderDefinedType);
+        final ProviderDefinedType nestedResult = (ProviderDefinedType) result.getFields().get("location");
+        assertEquals("Point", nestedResult.getName());
+        assertEquals(10, nestedResult.getFields().get("x"));
+        assertEquals(20, nestedResult.getFields().get("y"));
+    }
+
+    @Test
+    public void shouldHandleNullFieldValues() throws Exception {
+        final Map<String, Object> fields = new LinkedHashMap<>();
+        fields.put("name", "test");
+        fields.put("value", null);
+        final ProviderDefinedType pdt = new ProviderDefinedType("NullableType", fields);
+
+        final ProviderDefinedType result = serializeDeserialize(mapper, pdt, ProviderDefinedType.class);
+
+        assertEquals("NullableType", result.getName());
+        assertEquals("test", result.getFields().get("name"));
+        assertNull(result.getFields().get("value"));
+        assertTrue(result.getFields().containsKey("value"));
+    }
+
+    // --- Hydration tests ---
+
+    static class Point {
+        final int x;
+        final int y;
+        Point(int x, int y) { this.x = x; this.y = y; }
+    }
+
+    static class PointAdapter implements ProviderDefinedTypeAdapter<Point> {
+        @Override public String typeName() { return "Point"; }
+        @Override public Class<Point> targetClass() { return Point.class; }
+        @Override public Map<String, Object> toFields(Point obj) {
+            final Map<String, Object> m = new HashMap<>();
+            m.put("x", obj.x);
+            m.put("y", obj.y);
+            return m;
+        }
+        @Override public Point fromFields(Map<String, Object> fields) {
+            return new Point((int) fields.get("x"), (int) fields.get("y"));
+        }
+    }
+
+    @Test
+    public void shouldHydrateWhenRegistryConfigured() throws Exception {
+        final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+        registry.register(new PointAdapter());
+
+        final ObjectMapper hydratingMapper = GraphSONMapper.build()
+                .version(GraphSONVersion.V4_0)
+                .addCustomModule(GraphSONXModuleV4.build())
+                .typeInfo(TypeInfo.PARTIAL_TYPES)
+                .pdtRegistry(registry)
+                .create().createMapper();
+
+        final Map<String, Object> fields = new LinkedHashMap<>();
+        fields.put("x", 3);
+        fields.put("y", 7);
+        final ProviderDefinedType pdt = new ProviderDefinedType("Point", fields);
+
+        final ProviderDefinedType result = serializeDeserialize(hydratingMapper, pdt, ProviderDefinedType.class);
+
+        assertNotNull(result.getHydrated());
+        assertTrue(result.getHydrated() instanceof Point);
+        assertEquals(3, ((Point) result.getHydrated()).x);
+        assertEquals(7, ((Point) result.getHydrated()).y);
+    }
+
+    @Test
+    public void shouldNotHydrateWhenNoRegistryConfigured() throws Exception {
+        final Map<String, Object> fields = new LinkedHashMap<>();
+        fields.put("x", 1);
+        fields.put("y", 2);
+        final ProviderDefinedType pdt = new ProviderDefinedType("Point", fields);
+
+        final ProviderDefinedType result = serializeDeserialize(mapper, pdt, ProviderDefinedType.class);
+
+        assertNull(result.getHydrated());
+        assertEquals("Point", result.getName());
+        assertEquals(1, result.getFields().get("x"));
+    }
+
+    @Test
+    public void shouldReturnRawPdtWhenTypeNotRegistered() throws Exception {
+        final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+        // No adapter registered for "Unknown"
+
+        final ObjectMapper hydratingMapper = GraphSONMapper.build()
+                .version(GraphSONVersion.V4_0)
+                .addCustomModule(GraphSONXModuleV4.build())
+                .typeInfo(TypeInfo.PARTIAL_TYPES)
+                .pdtRegistry(registry)
+                .create().createMapper();
+
+        final Map<String, Object> fields = new LinkedHashMap<>();
+        fields.put("a", 1);
+        final ProviderDefinedType pdt = new ProviderDefinedType("Unknown", fields);
+
+        final ProviderDefinedType result = serializeDeserialize(hydratingMapper, pdt, ProviderDefinedType.class);
+
+        assertNull(result.getHydrated());
+        assertEquals("Unknown", result.getName());
+        assertEquals(1, result.getFields().get("a"));
+    }
+}
diff --git a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedTypeRegistryTest.java b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedTypeRegistryTest.java
new file mode 100644
index 0000000000..b710a2d67c
--- /dev/null
+++ b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedTypeRegistryTest.java
@@ -0,0 +1,416 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.io.pdt;
+
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class ProviderDefinedTypeRegistryTest {
+
+    // Simple test type
+    static class Point {
+        final int x;
+        final int y;
+        Point(int x, int y) { this.x = x; this.y = y; }
+    }
+
+    static class PointAdapter implements ProviderDefinedTypeAdapter<Point> {
+        @Override public String typeName() { return "Point"; }
+        @Override public Class<Point> targetClass() { return Point.class; }
+        @Override public Map<String, Object> toFields(Point obj) {
+            final Map<String, Object> m = new HashMap<>();
+            m.put("x", obj.x);
+            m.put("y", obj.y);
+            return m;
+        }
+        @Override public Point fromFields(Map<String, Object> fields) {
+            return new Point((int) fields.get("x"), (int) fields.get("y"));
+        }
+    }
+
+    // Nested test type
+    static class Line {
+        final Point start;
+        final Point end;
+        Line(Point start, Point end) { this.start = start; this.end = end; }
+    }
+
+    static class LineAdapter implements ProviderDefinedTypeAdapter<Line> {
+        @Override public String typeName() { return "Line"; }
+        @Override public Class<Line> targetClass() { return Line.class; }
+        @Override public Map<String, Object> toFields(Line obj) {
+            final Map<String, Object> m = new HashMap<>();
+            m.put("start", obj.start);
+            m.put("end", obj.end);
+            return m;
+        }
+        @Override public Line fromFields(Map<String, Object> fields) {
+            return new Line((Point) fields.get("start"), (Point) fields.get("end"));
+        }
+    }
+
+    // Adapter that always throws
+    static class FailingAdapter implements ProviderDefinedTypeAdapter<Point> {
+        @Override public String typeName() { return "Failing"; }
+        @Override public Class<Point> targetClass() { return Point.class; }
+        @Override public Map<String, Object> toFields(Point obj) { return new HashMap<>(); }
+        @Override public Point fromFields(Map<String, Object> fields) {
+            throw new RuntimeException("intentional failure");
+        }
+    }
+
+    @Test
+    public void shouldHydrateSimplePdt() {
+        final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+        registry.register(new PointAdapter());
+
+        final Map<String, Object> fields = new HashMap<>();
+        fields.put("x", 3);
+        fields.put("y", 7);
+        final ProviderDefinedType pdt = new ProviderDefinedType("Point", fields);
+
+        final Object result = registry.hydrate(pdt);
+        assertTrue(result instanceof Point);
+        assertEquals(3, ((Point) result).x);
+        assertEquals(7, ((Point) result).y);
+    }
+
+    @Test
+    public void shouldReturnRawPdtWhenNoAdapterRegistered() {
+        final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+
+        final Map<String, Object> fields = new HashMap<>();
+        fields.put("x", 1);
+        final ProviderDefinedType pdt = new ProviderDefinedType("Unknown", fields);
+
+        final Object result = registry.hydrate(pdt);
+        assertSame(pdt, result);
+    }
+
+    @Test
+    public void shouldHydrateNestedPdts() {
+        final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+        registry.register(new PointAdapter());
+        registry.register(new LineAdapter());
+
+        final Map<String, Object> startFields = new HashMap<>();
+        startFields.put("x", 0);
+        startFields.put("y", 0);
+        final Map<String, Object> endFields = new HashMap<>();
+        endFields.put("x", 5);
+        endFields.put("y", 5);
+
+        final Map<String, Object> lineFields = new HashMap<>();
+        lineFields.put("start", new ProviderDefinedType("Point", startFields));
+        lineFields.put("end", new ProviderDefinedType("Point", endFields));
+        final ProviderDefinedType linePdt = new ProviderDefinedType("Line", lineFields);
+
+        final Object result = registry.hydrate(linePdt);
+        assertTrue(result instanceof Line);
+        final Line line = (Line) result;
+        assertEquals(0, line.start.x);
+        assertEquals(0, line.start.y);
+        assertEquals(5, line.end.x);
+        assertEquals(5, line.end.y);
+    }
+
+    @Test
+    public void shouldPartiallyHydrateWhenInnerAdapterMissing() {
+        final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+        registry.register(new LineAdapter());
+        // Point adapter NOT registered
+
+        final Map<String, Object> startFields = new HashMap<>();
+        startFields.put("x", 1);
+        startFields.put("y", 2);
+        final ProviderDefinedType startPdt = new ProviderDefinedType("Point", startFields);
+
+        final Map<String, Object> endFields = new HashMap<>();
+        endFields.put("x", 3);
+        endFields.put("y", 4);
+        final ProviderDefinedType endPdt = new ProviderDefinedType("Point", endFields);
+
+        final Map<String, Object> lineFields = new HashMap<>();
+        lineFields.put("start", startPdt);
+        lineFields.put("end", endPdt);
+        final ProviderDefinedType linePdt = new ProviderDefinedType("Line", lineFields);
+
+        // Line adapter will receive ProviderDefinedType values for start/end since Point is not registered.
+        // The LineAdapter.fromFields casts to Point which will throw ClassCastException,
+        // so hydrate should fall back to returning the raw PDT.
+        final Object result = registry.hydrate(linePdt);
+        assertSame(linePdt, result);
+    }
+
+    @Test
+    public void shouldFallBackWhenAdapterThrows() {
+        final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+        registry.register(new FailingAdapter());
+
+        final Map<String, Object> fields = new HashMap<>();
+        fields.put("x", 1);
+        final ProviderDefinedType pdt = new ProviderDefinedType("Failing", fields);
+
+        // should not throw, should return raw PDT
+        final Object result = registry.hydrate(pdt);
+        assertSame(pdt, result);
+    }
+
+    @Test
+    public void shouldLookUpAdapterByClass() {
+        final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+        final PointAdapter adapter = new PointAdapter();
+        registry.register(adapter);
+
+        final Optional<ProviderDefinedTypeAdapter<?>> found = registry.getAdapterByClass(Point.class);
+        assertTrue(found.isPresent());
+        assertEquals("Point", found.get().typeName());
+    }
+
+    // Collection test type
+    static class Polygon {
+        final List<Point> vertices;
+        Polygon(List<Point> vertices) { this.vertices = vertices; }
+    }
+
+    static class PolygonAdapter implements ProviderDefinedTypeAdapter<Polygon> {
+        @Override public String typeName() { return "Polygon"; }
+        @Override public Class<Polygon> targetClass() { return Polygon.class; }
+        @Override public Map<String, Object> toFields(Polygon obj) {
+            final Map<String, Object> m = new HashMap<>();
+            m.put("vertices", obj.vertices);
+            return m;
+        }
+        @SuppressWarnings("unchecked")
+        @Override public Polygon fromFields(Map<String, Object> fields) {
+            return new Polygon((List<Point>) fields.get("vertices"));
+        }
+    }
+
+    @Test
+    public void shouldHydratePdtsInsideList() {
+        final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+        registry.register(new PointAdapter());
+        registry.register(new PolygonAdapter());
+
+        final Map<String, Object> p1 = new HashMap<>();
+        p1.put("x", 1); p1.put("y", 2);
+        final Map<String, Object> p2 = new HashMap<>();
+        p2.put("x", 3); p2.put("y", 4);
+
+        final Map<String, Object> polyFields = new HashMap<>();
+        polyFields.put("vertices", Arrays.asList(
+                new ProviderDefinedType("Point", p1),
+                new ProviderDefinedType("Point", p2)));
+        final ProviderDefinedType polyPdt = new ProviderDefinedType("Polygon", polyFields);
+
+        final Object result = registry.hydrate(polyPdt);
+        assertTrue(result instanceof Polygon);
+        final Polygon polygon = (Polygon) result;
+        assertEquals(2, polygon.vertices.size());
+        assertEquals(1, polygon.vertices.get(0).x);
+        assertEquals(2, polygon.vertices.get(0).y);
+        assertEquals(3, polygon.vertices.get(1).x);
+        assertEquals(4, polygon.vertices.get(1).y);
+    }
+
+    @Test
+    public void shouldHydratePdtsInsideMapValues() {
+        final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+        registry.register(new PointAdapter());
+
+        // A simple adapter that receives a map of named points
+        registry.register(new ProviderDefinedTypeAdapter<Map>() {
+            @Override public String typeName() { return "PointMap"; }
+            @Override public Class<Map> targetClass() { return Map.class; }
+            @Override public Map<String, Object> toFields(Map obj) { return new HashMap<>(); }
+            @SuppressWarnings("unchecked")
+            @Override public Map fromFields(Map<String, Object> fields) {
+                return (Map<String, Object>) fields.get("points");
+            }
+        });
+
+        final Map<String, Object> p1 = new HashMap<>();
+        p1.put("x", 10); p1.put("y", 20);
+        final Map<String, Object> p2 = new HashMap<>();
+        p2.put("x", 30); p2.put("y", 40);
+
+        final Map<String, Object> innerMap = new HashMap<>();
+        innerMap.put("origin", new ProviderDefinedType("Point", p1));
+        innerMap.put("target", new ProviderDefinedType("Point", p2));
+
+        final Map<String, Object> fields = new HashMap<>();
+        fields.put("points", innerMap);
+        final ProviderDefinedType pdt = new ProviderDefinedType("PointMap", fields);
+
+        final Object result = registry.hydrate(pdt);
+        assertTrue(result instanceof Map);
+        @SuppressWarnings("unchecked")
+        final Map<String, Object> resultMap = (Map<String, Object>) result;
+        assertTrue(resultMap.get("origin") instanceof Point);
+        assertTrue(resultMap.get("target") instanceof Point);
+        assertEquals(10, ((Point) resultMap.get("origin")).x);
+        assertEquals(40, ((Point) resultMap.get("target")).y);
+    }
+
+    @Test
+    public void shouldBuildViaServiceLoader() {
+        // ServiceLoader.load will find adapters on the classpath. With no META-INF/services file
+        // in test scope, this should produce an empty registry that still functions.
+        final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.create();
+
+        final Map<String, Object> fields = new HashMap<>();
+        fields.put("x", 1);
+        final ProviderDefinedType pdt = new ProviderDefinedType("Unregistered", fields);
+        final Object result = registry.hydrate(pdt);
+        assertSame(pdt, result);
+    }
+
+    // Annotated test types for register(Class<?>...)
+    @ProviderDefined(name = "AnnotatedPoint")
+    static class AnnotatedPoint {
+        public int x;
+        public int y;
+        public AnnotatedPoint() {}
+        public AnnotatedPoint(int x, int y) { this.x = x; this.y = y; }
+    }
+
+    @ProviderDefined(name = "Excluded", excludedFields = {"secret"})
+    static class ExcludedFields {
+        public int value;
+        public String secret;
+        public ExcludedFields() {}
+    }
+
+    @ProviderDefined(name = "NoCtor")
+    static class NoNoArgCtor {
+        public int x;
+        public NoNoArgCtor(int x) { this.x = x; }
+    }
+
+    static class NotAnnotated {
+        public int x;
+    }
+
+    @Test
+    public void shouldRegisterAndHydrateAnnotatedClass() {
+        final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+        registry.register(AnnotatedPoint.class);
+
+        final Map<String, Object> fields = new HashMap<>();
+        fields.put("x", 3);
+        fields.put("y", 7);
+        final Object result = registry.hydrate(new ProviderDefinedType("AnnotatedPoint", fields));
+
+        assertTrue(result instanceof AnnotatedPoint);
+        assertEquals(3, ((AnnotatedPoint) result).x);
+        assertEquals(7, ((AnnotatedPoint) result).y);
+    }
+
+    @Test
+    public void shouldDehydrateAnnotatedClassViaAdapter() {
+        final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+        registry.register(AnnotatedPoint.class);
+
+        final Optional<ProviderDefinedTypeAdapter<?>> adapter = registry.getAdapterByClass(AnnotatedPoint.class);
+        assertTrue(adapter.isPresent());
+        assertEquals("AnnotatedPoint", adapter.get().typeName());
+    }
+
+    @Test
+    public void shouldRespectExcludedFieldsWhenHydratingAnnotatedClass() {
+        final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+        registry.register(ExcludedFields.class);
+
+        final Map<String, Object> fields = new HashMap<>();
+        fields.put("value", 42);
+        fields.put("secret", "should-be-ignored");
+        final Object result = registry.hydrate(new ProviderDefinedType("Excluded", fields));
+
+        assertTrue(result instanceof ExcludedFields);
+        assertEquals(42, ((ExcludedFields) result).value);
+        // secret is excluded from the field mapping, so it is not set
+        assertEquals(null, ((ExcludedFields) result).secret);
+    }
+
+    @Test
+    public void shouldThrowWhenRegisteringNonAnnotatedClass() {
+        final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+        try {
+            registry.register(NotAnnotated.class);
+            fail("Expected IllegalArgumentException for non-annotated class");
+        } catch (final IllegalArgumentException e) {
+            assertTrue(e.getMessage().contains("not annotated with @ProviderDefined"));
+        }
+    }
+
+    @Test
+    public void shouldThrowWhenRegisteringClassWithoutNoArgConstructor() {
+        final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+        try {
+            registry.register(NoNoArgCtor.class);
+            fail("Expected IllegalArgumentException for class without no-arg constructor");
+        } catch (final IllegalArgumentException e) {
+            assertTrue(e.getMessage().contains("no-arg constructor"));
+        }
+    }
+
+    @Test
+    public void shouldHydrateNestedRegisteredTypeInsideUnregisteredOuter() {
+        // Contract: a registered type ALWAYS hydrates even when nested inside an unregistered outer PDT.
+        // The outer "Container" has no adapter, so it remains a raw ProviderDefinedType,
+        // but the inner "Point" field value should be hydrated to a Point instance.
+        final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+        registry.register(new PointAdapter());
+
+        final Map<String, Object> pointFields = new HashMap<>();
+        pointFields.put("x", 10);
+        pointFields.put("y", 20);
+        final ProviderDefinedType innerPointPdt = new ProviderDefinedType("Point", pointFields);
+
+        final Map<String, Object> containerFields = new HashMap<>();
+        containerFields.put("location", innerPointPdt);
+        final ProviderDefinedType outerPdt = new ProviderDefinedType("Container", containerFields);
+
+        final Object result = registry.hydrate(outerPdt);
+
+        // Outer should remain a raw ProviderDefinedType (no adapter for "Container")
+        assertTrue("Expected outer to remain a ProviderDefinedType", result instanceof ProviderDefinedType);
+        final ProviderDefinedType resultPdt = (ProviderDefinedType) result;
+        assertEquals("Container", resultPdt.getName());
+
+        // Inner "location" field should be hydrated to a Point instance
+        final Object innerValue = resultPdt.getFields().get("location");
+        assertTrue("Expected inner field to be hydrated to Point but was: " +
+                (innerValue == null ? "null" : innerValue.getClass().getName()),
+                innerValue instanceof Point);
+        assertEquals(10, ((Point) innerValue).x);
+        assertEquals(20, ((Point) innerValue).y);
+    }
+}
diff --git a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedTypeTest.java b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedTypeTest.java
new file mode 100644
index 0000000000..32153e591c
--- /dev/null
+++ b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/pdt/ProviderDefinedTypeTest.java
@@ -0,0 +1,227 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.structure.io.pdt;
+
+import org.junit.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertThrows;
+
+public class ProviderDefinedTypeTest {
+
+    @ProviderDefined
+    static class Point {
+        int x = 1;
+        int y = 2;
+    }
+
+    @ProviderDefined(name = "GeoPoint")
+    static class NamedPoint {
+        double lat = 45.0;
+        double lon = -93.0;
+    }
+
+    @ProviderDefined(includedFields = {"x"})
+    static class IncludedFieldsPoint {
+        int x = 10;
+        int y = 20;
+        int z = 30;
+    }
+
+    @ProviderDefined(excludedFields = {"z"})
+    static class ExcludedFieldsPoint {
+        int x = 10;
+        int y = 20;
+        int z = 30;
+    }
+
+    @ProviderDefined(includedFields = {"x"}, excludedFields = {"z"})
+    static class ConflictingFieldsPoint {
+        int x = 10;
+        int y = 20;
+        int z = 30;
+    }
+
+    @ProviderDefined
+    static class NullFieldPoint {
+        String label = null;
+        int x = 5;
+    }
+
+    static class NotAnnotated {
+        int value = 1;
+    }
+
+    static class BasePoint {
+        int x = 1;
+        int y = 2;
+    }
+
+    @ProviderDefined(name = "GeoPoint")
+    static class InheritedPoint extends BasePoint {
+        String label = "origin";
+    }
+
+    @ProviderDefined(excludedFields = {"y"})
+    static class InheritedExcluded extends BasePoint {
+        String label = "test";
+    }
+
+    @ProviderDefined(includedFields = {"x", "label"})
+    static class InheritedIncluded extends BasePoint {
+        String label = "included";
+    }
+
+    @Test
+    public void shouldConstructDirectly() {
+        final Map<String, Object> fields = new HashMap<>();
+        fields.put("x", 1);
+        fields.put("y", 2);
+        final ProviderDefinedType pdt = new ProviderDefinedType("Point", fields);
+        assertEquals("Point", pdt.getName());
+        assertEquals(fields, pdt.getFields());
+    }
+
+    @Test
+    public void shouldBeImmutableFromInputMap() {
+        final Map<String, Object> fields = new HashMap<>();
+        fields.put("x", 1);
+        final ProviderDefinedType pdt = new ProviderDefinedType("Point", fields);
+        fields.put("y", 2);
+        assertEquals(1, pdt.getFields().size());
+    }
+
+    @Test
+    public void shouldReturnUnmodifiableFields() {
+        final Map<String, Object> fields = new HashMap<>();
+        fields.put("x", 1);
+        final ProviderDefinedType pdt = new ProviderDefinedType("Point", fields);
+        assertThrows(UnsupportedOperationException.class, () -> pdt.getFields().put("y", 2));
+    }
+
+    @Test
+    public void shouldCreateFromAnnotatedObject() {
+        final ProviderDefinedType pdt = ProviderDefinedType.from(new Point());
+        assertEquals("Point", pdt.getName());
+        assertEquals(1, pdt.getFields().get("x"));
+        assertEquals(2, pdt.getFields().get("y"));
+    }
+
+    @Test
+    public void shouldUseCustomNameFromAnnotation() {
+        final ProviderDefinedType pdt = ProviderDefinedType.from(new NamedPoint());
+        assertEquals("GeoPoint", pdt.getName());
+    }
+
+    @Test
+    public void shouldFilterWithIncludedFields() {
+        final ProviderDefinedType pdt = ProviderDefinedType.from(new IncludedFieldsPoint());
+        assertEquals(1, pdt.getFields().size());
+        assertEquals(10, pdt.getFields().get("x"));
+    }
+
+    @Test
+    public void shouldFilterWithExcludedFields() {
+        final ProviderDefinedType pdt = ProviderDefinedType.from(new ExcludedFieldsPoint());
+        assertEquals(2, pdt.getFields().size());
+        assertEquals(10, pdt.getFields().get("x"));
+        assertEquals(20, pdt.getFields().get("y"));
+    }
+
+    @Test
+    public void shouldThrowOnNullObject() {
+        assertThrows(IllegalArgumentException.class, () -> ProviderDefinedType.from(null));
+    }
+
+    @Test
+    public void shouldThrowOnNonAnnotatedObject() {
+        assertThrows(IllegalArgumentException.class, () -> ProviderDefinedType.from(new NotAnnotated()));
+    }
+
+    @Test
+    public void shouldHaveCorrectEqualsAndHashCode() {
+        final Map<String, Object> fields = new HashMap<>();
+        fields.put("x", 1);
+        final ProviderDefinedType a = new ProviderDefinedType("Point", fields);
+        final ProviderDefinedType b = new ProviderDefinedType("Point", fields);
+        assertEquals(a, b);
+        assertEquals(a.hashCode(), b.hashCode());
+
+        final ProviderDefinedType c = new ProviderDefinedType("Other", fields);
+        assertNotEquals(a, c);
+    }
+
+    @Test
+    public void shouldThrowOnNullName() {
+        assertThrows(IllegalArgumentException.class, () -> new ProviderDefinedType(null, new HashMap<>()));
+    }
+
+    @Test
+    public void shouldThrowOnEmptyName() {
+        assertThrows(IllegalArgumentException.class, () -> new ProviderDefinedType("", new HashMap<>()));
+    }
+
+    @Test
+    public void shouldThrowOnNullFields() {
+        assertThrows(IllegalArgumentException.class, () -> new ProviderDefinedType("Point", null));
+    }
+
+    @Test
+    public void shouldPreserveNullFieldValues() {
+        final ProviderDefinedType pdt = ProviderDefinedType.from(new NullFieldPoint());
+        assertEquals(2, pdt.getFields().size());
+        assertEquals(null, pdt.getFields().get("label"));
+        assertEquals(5, pdt.getFields().get("x"));
+    }
+
+    @Test
+    public void shouldThrowOnConflictingIncludedAndExcludedFields() {
+        assertThrows(IllegalArgumentException.class, () -> ProviderDefinedType.from(new ConflictingFieldsPoint()));
+    }
+
+    @Test
+    public void shouldIncludeInheritedFields() {
+        final ProviderDefinedType pdt = ProviderDefinedType.from(new InheritedPoint());
+        assertEquals("GeoPoint", pdt.getName());
+        assertEquals(3, pdt.getFields().size());
+        assertEquals("origin", pdt.getFields().get("label"));
+        assertEquals(1, pdt.getFields().get("x"));
+        assertEquals(2, pdt.getFields().get("y"));
+    }
+
+    @Test
+    public void shouldExcludeInheritedFields() {
+        final ProviderDefinedType pdt = ProviderDefinedType.from(new InheritedExcluded());
+        assertEquals(2, pdt.getFields().size());
+        assertEquals("test", pdt.getFields().get("label"));
+        assertEquals(1, pdt.getFields().get("x"));
+    }
+
+    @Test
+    public void shouldIncludeOnlySpecifiedFieldsAcrossHierarchy() {
+        final ProviderDefinedType pdt = ProviderDefinedType.from(new InheritedIncluded());
+        assertEquals(2, pdt.getFields().size());
+        assertEquals("included", pdt.getFields().get("label"));
+        assertEquals(1, pdt.getFields().get("x"));
+    }
+}
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinClient.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinClient.cs
index 8a7b1d381b..4599e47de5 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinClient.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/GremlinClient.cs
@@ -27,6 +27,7 @@ using System.Collections.Generic;
 using System.Threading;
 using System.Threading.Tasks;
 using Gremlin.Net.Driver.Messages;
+using Gremlin.Net.Structure;
 using Gremlin.Net.Structure.IO.GraphBinary4;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging.Abstractions;
@@ -65,15 +66,26 @@ namespace Gremlin.Net.Driver
         ///     <see cref="HttpRequestContext" /> and can modify headers, body, URI, and method
         ///     before the request is sent.
         /// </param>
+        /// <param name="pdtRegistry">
+        ///     An optional <see cref="ProviderDefinedTypeRegistry"/> for automatic hydration of
+        ///     provider-defined types.
+        /// </param>
         public GremlinClient(GremlinServer gremlinServer, IMessageSerializer? requestSerializer,
             IMessageSerializer responseSerializer,
             ConnectionSettings? connectionSettings = null,
             ILoggerFactory? loggerFactory = null,
-            IReadOnlyList<Func<HttpRequestContext, Task>>? interceptors = null)
+            IReadOnlyList<Func<HttpRequestContext, Task>>? interceptors = null,
+            ProviderDefinedTypeRegistry? pdtRegistry = null)
         {
             connectionSettings ??= new ConnectionSettings();
             LoggerFactory = loggerFactory ?? NullLoggerFactory.Instance;
 
+            if (pdtRegistry != null)
+            {
+                requestSerializer?.SetPdtRegistry(pdtRegistry);
+                responseSerializer.SetPdtRegistry(pdtRegistry);
+            }
+
             _connection = new Connection(
                 gremlinServer.Uri,
                 requestSerializer,
@@ -100,14 +112,19 @@ namespace Gremlin.Net.Driver
         /// <param name="interceptors">
         ///     An optional list of request interceptors.
         /// </param>
+        /// <param name="pdtRegistry">
+        ///     An optional <see cref="ProviderDefinedTypeRegistry"/> for automatic hydration of
+        ///     provider-defined types.
+        /// </param>
         public GremlinClient(GremlinServer gremlinServer, IMessageSerializer? messageSerializer = null,
             ConnectionSettings? connectionSettings = null,
             ILoggerFactory? loggerFactory = null,
-            IReadOnlyList<Func<HttpRequestContext, Task>>? interceptors = null)
+            IReadOnlyList<Func<HttpRequestContext, Task>>? interceptors = null,
+            ProviderDefinedTypeRegistry? pdtRegistry = null)
             : this(gremlinServer,
                   messageSerializer ?? new GraphBinary4MessageSerializer(),
                   messageSerializer ?? new GraphBinary4MessageSerializer(),
-                  connectionSettings, loggerFactory, interceptors)
+                  connectionSettings, loggerFactory, interceptors, pdtRegistry)
         {
         }
 
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/IMessageSerializer.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/IMessageSerializer.cs
index 9abb59dc66..831a8ee379 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Driver/IMessageSerializer.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/IMessageSerializer.cs
@@ -26,6 +26,7 @@ using System.IO;
 using System.Threading;
 using System.Threading.Tasks;
 using Gremlin.Net.Driver.Messages;
+using Gremlin.Net.Structure;
 
 namespace Gremlin.Net.Driver
 {
@@ -61,5 +62,12 @@ namespace Gremlin.Net.Driver
         /// <returns>An async sequence of deserialized result objects.</returns>
         IAsyncEnumerable<object> DeserializeMessageAsync(Stream stream,
             CancellationToken cancellationToken = default);
+
+        /// <summary>
+        ///     Sets the <see cref="ProviderDefinedTypeRegistry"/> for automatic hydration
+        ///     of provider-defined types during deserialization. The default implementation
+        ///     is a no-op for serializers that do not support PDT hydration.
+        /// </summary>
+        void SetPdtRegistry(ProviderDefinedTypeRegistry pdtRegistry) { }
     }
 }
\ No newline at end of file
diff --git a/gremlin-dotnet/src/Gremlin.Net/Driver/Remote/DriverRemoteConnection.cs b/gremlin-dotnet/src/Gremlin.Net/Driver/Remote/DriverRemoteConnection.cs
index abcaa28d32..e6d5c032f9 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Driver/Remote/DriverRemoteConnection.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Driver/Remote/DriverRemoteConnection.cs
@@ -29,6 +29,7 @@ using Gremlin.Net.Driver.Messages;
 using Gremlin.Net.Process.Remote;
 using Gremlin.Net.Process.Traversal;
 using Gremlin.Net.Process.Traversal.Strategy.Decoration;
+using Gremlin.Net.Structure;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging.Abstractions;
 
@@ -43,6 +44,11 @@ namespace Gremlin.Net.Driver.Remote
         private readonly string _traversalSource;
         private readonly ILogger<DriverRemoteConnection> _logger;
 
+        /// <summary>
+        ///     Gets or sets the <see cref="ProviderDefinedTypeRegistry"/> for registry-based dehydration.
+        /// </summary>
+        public ProviderDefinedTypeRegistry? PdtRegistry { get; set; }
+
         // All OptionsStrategy keys are passed through to the request fields.
         // The server filters out options that don't apply, and this allows
         // providers to use custom request fields via the Client directly or DRC.
@@ -58,14 +64,17 @@ namespace Gremlin.Net.Driver.Remote
         ///     An optional list of request interceptors forwarded to the underlying
         ///     <see cref="GremlinClient" />.
         /// </param>
+        /// <param name="pdtRegistry">An optional registry for PDT hydration.</param>
         /// <exception cref="ArgumentNullException">Thrown when client is null.</exception>
         public DriverRemoteConnection(string host, int port, string traversalSource = "g",
             ILoggerFactory? loggerFactory = null,
-            IReadOnlyList<Func<HttpRequestContext, Task>>? interceptors = null) : this(
-            new GremlinClient(new GremlinServer(host, port), loggerFactory: loggerFactory, interceptors: interceptors),
+            IReadOnlyList<Func<HttpRequestContext, Task>>? interceptors = null,
+            ProviderDefinedTypeRegistry? pdtRegistry = null) : this(
+            new GremlinClient(new GremlinServer(host, port), loggerFactory: loggerFactory, interceptors: interceptors, pdtRegistry: pdtRegistry),
             traversalSource,
             logger: loggerFactory?.CreateLogger<DriverRemoteConnection>() ?? NullLogger<DriverRemoteConnection>.Instance)
         {
+            PdtRegistry = pdtRegistry;
         }
 
         /// <summary>
@@ -73,10 +82,13 @@ namespace Gremlin.Net.Driver.Remote
         /// </summary>
         /// <param name="client">The <see cref="IGremlinClient" /> that will be used for the connection.</param>
         /// <param name="traversalSource">The name of the traversal source on the server to bind to.</param>
+        /// <param name="pdtRegistry">An optional registry for PDT hydration.</param>
         /// <exception cref="ArgumentNullException">Thrown when client or the traversalSource is null.</exception>
-        public DriverRemoteConnection(IGremlinClient client, string traversalSource = "g")
+        public DriverRemoteConnection(IGremlinClient client, string traversalSource = "g",
+            ProviderDefinedTypeRegistry? pdtRegistry = null)
             : this(client, traversalSource, logger: null)
         {
+            PdtRegistry = pdtRegistry;
         }
 
         private DriverRemoteConnection(IGremlinClient client, string traversalSource,
diff --git a/gremlin-dotnet/src/Gremlin.Net/Process/Remote/IRemoteConnection.cs b/gremlin-dotnet/src/Gremlin.Net/Process/Remote/IRemoteConnection.cs
index bbef10f47c..33a7388548 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Process/Remote/IRemoteConnection.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Process/Remote/IRemoteConnection.cs
@@ -25,6 +25,7 @@ using System.Threading;
 using System.Threading.Tasks;
 using Gremlin.Net.Driver;
 using Gremlin.Net.Process.Traversal;
+using Gremlin.Net.Structure;
 
 namespace Gremlin.Net.Process.Remote
 {
@@ -52,5 +53,10 @@ namespace Gremlin.Net.Process.Remote
         /// </param>
         /// <returns>The created <see cref="RemoteTransaction" />.</returns>
         RemoteTransaction Tx(GraphTraversalSource graphTraversalSource);
+
+        /// <summary>
+        ///     Gets the <see cref="ProviderDefinedTypeRegistry"/> for registry-based dehydration, or null.
+        /// </summary>
+        ProviderDefinedTypeRegistry? PdtRegistry => null;
     }
 }
\ No newline at end of file
diff --git a/gremlin-dotnet/src/Gremlin.Net/Process/Traversal/AnonymousTraversalSource.cs b/gremlin-dotnet/src/Gremlin.Net/Process/Traversal/AnonymousTraversalSource.cs
index aff2d4c2d1..fa22be6199 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Process/Traversal/AnonymousTraversalSource.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Process/Traversal/AnonymousTraversalSource.cs
@@ -58,7 +58,7 @@ namespace Gremlin.Net.Process.Traversal
         /// <returns>A <see cref="GraphTraversalSource" /> configured to use the provided <see cref="IRemoteConnection" />.</returns>
         public GraphTraversalSource With(IRemoteConnection remoteConnection) =>
             new GraphTraversalSource(new List<ITraversalStrategy>(),
-                new GremlinLang(), remoteConnection);
+                new GremlinLang { PdtRegistry = remoteConnection?.PdtRegistry }, remoteConnection!);
     }
 
 }
\ No newline at end of file
diff --git a/gremlin-dotnet/src/Gremlin.Net/Process/Traversal/GremlinLang.cs b/gremlin-dotnet/src/Gremlin.Net/Process/Traversal/GremlinLang.cs
index 21115a8f8f..684a76c2f6 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Process/Traversal/GremlinLang.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Process/Traversal/GremlinLang.cs
@@ -23,9 +23,11 @@
 
 using System;
 using System.Collections;
+using System.Collections.Concurrent;
 using System.Collections.Generic;
 using System.Globalization;
 using System.Numerics;
+using System.Reflection;
 using System.Text;
 using Gremlin.Net.Process.Traversal.Strategy;
 using Gremlin.Net.Process.Traversal.Strategy.Decoration;
@@ -40,11 +42,17 @@ namespace Gremlin.Net.Process.Traversal
     public class GremlinLang : ICloneable, IEquatable<GremlinLang>
     {
         private static readonly object[] EmptyArray = Array.Empty<object>();
+        private static readonly ConcurrentDictionary<Type, (string name, PropertyInfo[] props)?> _pdtCache = new();
 
         private StringBuilder _gremlin = new();
         private Dictionary<string, object> _parameters = new();
         private List<OptionsStrategy> _optionsStrategies = new();
 
+        /// <summary>
+        ///     Gets or sets the <see cref="ProviderDefinedTypeRegistry"/> for registry-based dehydration.
+        /// </summary>
+        public ProviderDefinedTypeRegistry? PdtRegistry { get; set; }
+
         /// <summary>
         ///     Initializes a new instance of the <see cref="GremlinLang" /> class.
         /// </summary>
@@ -329,6 +337,25 @@ namespace Gremlin.Net.Process.Traversal
             if (arg is CardinalityValue cv)
                 return $"Cardinality.{cv.Cardinality!.EnumValue}({ArgAsString(cv.Value)})";
 
+            if (arg is ProviderDefinedType pdt)
+            {
+                var sb2 = new StringBuilder("[");
+                var count = pdt.Fields.Count;
+                if (count == 0)
+                {
+                    sb2.Append(':');
+                }
+                else
+                {
+                    foreach (var kvp in pdt.Fields)
+                    {
+                        sb2.Append(ArgAsString(kvp.Key)).Append(':').Append(ArgAsString(kvp.Value));
+                        if (--count > 0) sb2.Append(',');
+                    }
+                }
+                sb2.Append(']');
+                return $"PDT(\"{EscapeJava(pdt.Name)}\",{sb2})";
+            }
             if (arg is IDictionary dict)
                 return AsString(dict);
 
@@ -348,6 +375,34 @@ namespace Gremlin.Net.Process.Traversal
             if (arg is Type type)
                 return type.Name;
 
+            // Precedence: a registered adapter intentionally takes priority over the [ProviderDefined]
+            // attribute so that explicit adapters can override attribute-derived dehydration behavior.
+            if (PdtRegistry != null)
+            {
+                var adapterInfo = PdtRegistry.GetAdapterByType(arg.GetType());
+                if (adapterInfo != null)
+                {
+                    var (adapterTypeName, toFields) = adapterInfo.Value;
+                    var fields = toFields(arg);
+                    return ArgAsString(new ProviderDefinedType(adapterTypeName,
+                        new Dictionary<string, object?>(fields)));
+                }
+            }
+
+            // Auto-dehydrate objects annotated with [ProviderDefined]
+            var cached = GetPdtInfo(arg.GetType());
+            if (cached != null)
+            {
+                var (typeName, properties) = cached.Value;
+                ProviderDefinedAttribute.RegisteredTypes.TryAdd(typeName, arg.GetType());
+                var fields = new Dictionary<string, object?>();
+                foreach (var property in properties)
+                {
+                    fields[property.Name] = property.GetValue(arg);
+                }
+                return ArgAsString(new ProviderDefinedType(typeName, fields));
+            }
+
             throw new ArgumentException(
                 $"GremlinLang contains at least one type [{arg.GetType().Name}] that cannot be represented as text.");
         }
@@ -728,6 +783,39 @@ namespace Gremlin.Net.Process.Traversal
             return true;
         }
 
+        private static (string name, PropertyInfo[] props)? GetPdtInfo(Type type)
+        {
+            return _pdtCache.GetOrAdd(type, t =>
+            {
+                var attrs = t.GetCustomAttributes(typeof(ProviderDefinedAttribute), false);
+                if (attrs.Length == 0) return null;
+
+                var attr = (ProviderDefinedAttribute)attrs[0];
+                var typeName = string.IsNullOrEmpty(attr.Name) ? t.Name : attr.Name;
+
+                var included = attr.IncludedFields;
+                var excluded = attr.ExcludedFields;
+                if (included is { Length: > 0 } && excluded is { Length: > 0 })
+                {
+                    throw new ArgumentException(
+                        "[ProviderDefined] cannot specify both IncludedFields and ExcludedFields");
+                }
+
+                var includedSet = included is { Length: > 0 } ? new HashSet<string>(included) : null;
+                var excludedSet = excluded is { Length: > 0 } ? new HashSet<string>(excluded) : null;
+
+                var allProps = t.GetProperties(BindingFlags.Public | BindingFlags.Instance);
+                var filtered = new List<PropertyInfo>();
+                foreach (var p in allProps)
+                {
+                    if (includedSet != null && !includedSet.Contains(p.Name)) continue;
+                    if (excludedSet != null && excludedSet.Contains(p.Name)) continue;
+                    filtered.Add(p);
+                }
+                return (typeName, filtered.ToArray());
+            });
+        }
+
         /// <summary>
         ///     Creates a deep copy of this <see cref="GremlinLang" /> instance.
         /// </summary>
@@ -738,7 +826,8 @@ namespace Gremlin.Net.Process.Traversal
             {
                 _gremlin = new StringBuilder(_gremlin.ToString()),
                 _parameters = new Dictionary<string, object>(_parameters),
-                _optionsStrategies = new List<OptionsStrategy>(_optionsStrategies)
+                _optionsStrategies = new List<OptionsStrategy>(_optionsStrategies),
+                PdtRegistry = PdtRegistry
             };
             return clone;
         }
diff --git a/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary4/DataType.cs b/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary4/DataType.cs
index 23a4755895..a8844bbb47 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary4/DataType.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary4/DataType.cs
@@ -58,8 +58,8 @@ namespace Gremlin.Net.Structure.IO.GraphBinary4
         // Not yet implemented
         // public static readonly DataType Tree = new DataType(0x2B);
         public static readonly DataType Merge = new DataType(0x2E);
+        public static readonly DataType CompositePDT = new DataType(0xF0);
         // Not yet implemented
-        // public static readonly DataType CompositePDT = new DataType(0xF0);
         // public static readonly DataType PrimitivePDT = new DataType(0xF1);
         public static readonly DataType Char = new DataType(0x80);
         public static readonly DataType Duration = new DataType(0x81);
diff --git a/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary4/GraphBinary4MessageSerializer.cs b/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary4/GraphBinary4MessageSerializer.cs
index 706ab47dd0..d336ebfab8 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary4/GraphBinary4MessageSerializer.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary4/GraphBinary4MessageSerializer.cs
@@ -36,7 +36,7 @@ namespace Gremlin.Net.Structure.IO.GraphBinary4
     /// </summary>
     public class GraphBinary4MessageSerializer : IMessageSerializer
     {
-        private readonly GraphBinaryReader _reader;
+        private GraphBinaryReader _reader;
         private readonly GraphBinaryWriter _writer;
         private readonly RequestMessageSerializer _requestSerializer = new RequestMessageSerializer();
         private readonly ResponseSerializer _responseSerializer = new ResponseSerializer();
@@ -54,6 +54,25 @@ namespace Gremlin.Net.Structure.IO.GraphBinary4
             _writer = new GraphBinaryWriter();
         }
 
+        /// <summary>
+        ///     Initializes a new instance of the <see cref="GraphBinary4MessageSerializer" /> class
+        ///     with a <see cref="ProviderDefinedTypeRegistry"/> for automatic hydration.
+        /// </summary>
+        public GraphBinary4MessageSerializer(ProviderDefinedTypeRegistry pdtRegistry)
+        {
+            _reader = new GraphBinaryReader(pdtRegistry: pdtRegistry);
+            _writer = new GraphBinaryWriter();
+        }
+
+        /// <summary>
+        ///     Sets the <see cref="ProviderDefinedTypeRegistry"/> on this serializer's reader
+        ///     for automatic hydration of provider-defined types.
+        /// </summary>
+        public void SetPdtRegistry(ProviderDefinedTypeRegistry pdtRegistry)
+        {
+            _reader = new GraphBinaryReader(pdtRegistry: pdtRegistry);
+        }
+
         /// <inheritdoc />
         public async Task<byte[]> SerializeMessageAsync(RequestMessage requestMessage,
             CancellationToken cancellationToken = default)
diff --git a/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary4/GraphBinaryReader.cs b/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary4/GraphBinaryReader.cs
index ffe9f08f97..97f0dd76fe 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary4/GraphBinaryReader.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary4/GraphBinaryReader.cs
@@ -24,6 +24,7 @@
 using System.IO;
 using System.Threading;
 using System.Threading.Tasks;
+using Gremlin.Net.Structure;
 
 namespace Gremlin.Net.Structure.IO.GraphBinary4
 {
@@ -33,14 +34,17 @@ namespace Gremlin.Net.Structure.IO.GraphBinary4
     public class GraphBinaryReader
     {
         private readonly TypeSerializerRegistry _registry;
+        private readonly ProviderDefinedTypeRegistry? _pdtRegistry;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="GraphBinaryReader" /> class.
         /// </summary>
         /// <param name="registry">The <see cref="TypeSerializerRegistry"/> to use for deserialization.</param>
-        public GraphBinaryReader(TypeSerializerRegistry? registry = null)
+        /// <param name="pdtRegistry">Optional <see cref="ProviderDefinedTypeRegistry"/> for automatic hydration.</param>
+        public GraphBinaryReader(TypeSerializerRegistry? registry = null, ProviderDefinedTypeRegistry? pdtRegistry = null)
         {
             _registry = registry ?? TypeSerializerRegistry.Instance;
+            _pdtRegistry = pdtRegistry;
         }
 
         /// <summary>
@@ -90,7 +94,18 @@ namespace Gremlin.Net.Structure.IO.GraphBinary4
             }
 
             var typeSerializer = _registry.GetSerializerFor(type);
-            return await typeSerializer.ReadAsync(stream, this, cancellationToken).ConfigureAwait(false);
+            var result = await typeSerializer.ReadAsync(stream, this, cancellationToken).ConfigureAwait(false);
+            if (result is ProviderDefinedType pdt)
+            {
+                if (_pdtRegistry != null)
+                {
+                    var hydrated = _pdtRegistry.Hydrate(pdt);
+                    if (hydrated is not ProviderDefinedType)
+                        return hydrated;
+                }
+                return ProviderDefinedAttribute.HydrateIfRegistered(pdt);
+            }
+            return result;
         }
     }
 }
\ No newline at end of file
diff --git a/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary4/TypeSerializerRegistry.cs b/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary4/TypeSerializerRegistry.cs
index 958e8d5c88..8c48079668 100644
--- a/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary4/TypeSerializerRegistry.cs
+++ b/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary4/TypeSerializerRegistry.cs
@@ -65,6 +65,7 @@ namespace Gremlin.Net.Structure.IO.GraphBinary4
                 {typeof(char), new CharSerializer()},
                 {typeof(TimeSpan), new DurationSerializer()},
                 {typeof(Marker), SingleTypeSerializers.MarkerSerializer},
+                {typeof(ProviderDefinedType), new CompositePDTSerializer()},
             };
 
         private readonly Dictionary<DataType, ITypeSerializer> _serializerByDataType =
@@ -98,6 +99,7 @@ namespace Gremlin.Net.Structure.IO.GraphBinary4
                 {DataType.Char, new CharSerializer()},
                 {DataType.Duration, new DurationSerializer()},
                 {DataType.Marker, SingleTypeSerializers.MarkerSerializer},
+                {DataType.CompositePDT, new CompositePDTSerializer()},
             };
 
         /// <summary>
diff --git a/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary4/Types/CompositePDTSerializer.cs b/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary4/Types/CompositePDTSerializer.cs
new file mode 100644
index 0000000000..10e2a082fa
--- /dev/null
+++ b/gremlin-dotnet/src/Gremlin.Net/Structure/IO/GraphBinary4/Types/CompositePDTSerializer.cs
@@ -0,0 +1,78 @@
+#region License
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#endregion
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Gremlin.Net.Structure.IO.GraphBinary4.Types
+{
+    /// <summary>
+    /// A <see cref="ProviderDefinedType"/> serializer for the CompositePDT data type.
+    /// </summary>
+    public class CompositePDTSerializer : SimpleTypeSerializer<ProviderDefinedType>
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="CompositePDTSerializer"/> class.
+        /// </summary>
+        public CompositePDTSerializer() : base(DataType.CompositePDT)
+        {
+        }
+
+        /// <inheritdoc />
+        protected override async Task WriteValueAsync(ProviderDefinedType value, Stream stream,
+            GraphBinaryWriter writer, CancellationToken cancellationToken = default)
+        {
+            // Write name as fully-qualified string
+            await writer.WriteAsync(value.Name, stream, cancellationToken).ConfigureAwait(false);
+            // Write fields as fully-qualified map
+            await writer.WriteAsync((IDictionary<string, object?>)new Dictionary<string, object?>(value.Fields),
+                stream, cancellationToken).ConfigureAwait(false);
+        }
+
+        /// <inheritdoc />
+        protected override async Task<ProviderDefinedType> ReadValueAsync(Stream stream, GraphBinaryReader reader,
+            CancellationToken cancellationToken = default)
+        {
+            var name = await reader.ReadAsync(stream, cancellationToken).ConfigureAwait(false) as string;
+            if (string.IsNullOrEmpty(name))
+                throw new IOException("CompositePDT name cannot be null or empty.");
+
+            var map = await reader.ReadAsync(stream, cancellationToken).ConfigureAwait(false)
+                as IDictionary<object, object?>;
+
+            var fields = new Dictionary<string, object?>();
+            if (map != null)
+            {
+                foreach (var kv in map)
+                {
+                    fields[(string)kv.Key] = kv.Value;
+                }
+            }
+
+            return new ProviderDefinedType(name!, fields);
+        }
+    }
+}
diff --git a/gremlin-dotnet/src/Gremlin.Net/Structure/IProviderDefinedTypeAdapter.cs b/gremlin-dotnet/src/Gremlin.Net/Structure/IProviderDefinedTypeAdapter.cs
new file mode 100644
index 0000000000..89dce66cfc
--- /dev/null
+++ b/gremlin-dotnet/src/Gremlin.Net/Structure/IProviderDefinedTypeAdapter.cs
@@ -0,0 +1,49 @@
+#region License
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#endregion
+
+using System.Collections.Generic;
+
+namespace Gremlin.Net.Structure
+{
+    /// <summary>
+    /// Adapter for hydrating a <see cref="ProviderDefinedType"/> into a strongly-typed object.
+    /// </summary>
+    /// <typeparam name="T">The target type to hydrate into.</typeparam>
+    public interface IProviderDefinedTypeAdapter<T>
+    {
+        /// <summary>
+        /// Gets the fully-qualified type name this adapter handles.
+        /// </summary>
+        string TypeName { get; }
+
+        /// <summary>
+        /// Creates a typed instance from the PDT fields.
+        /// </summary>
+        T FromFields(IReadOnlyDictionary<string, object?> fields);
+
+        /// <summary>
+        /// Converts a typed instance back to PDT fields.
+        /// </summary>
+        IReadOnlyDictionary<string, object?> ToFields(T obj);
+    }
+}
diff --git a/gremlin-dotnet/src/Gremlin.Net/Structure/ProviderDefinedAttribute.cs b/gremlin-dotnet/src/Gremlin.Net/Structure/ProviderDefinedAttribute.cs
new file mode 100644
index 0000000000..c13dfb853e
--- /dev/null
+++ b/gremlin-dotnet/src/Gremlin.Net/Structure/ProviderDefinedAttribute.cs
@@ -0,0 +1,107 @@
+#region License
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#endregion
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Reflection;
+
+namespace Gremlin.Net.Structure
+{
+    /// <summary>
+    /// Marks a class as a provider-defined type target for hydration.
+    /// </summary>
+    [AttributeUsage(AttributeTargets.Class)]
+    public class ProviderDefinedAttribute : Attribute
+    {
+        /// <summary>
+        /// Gets or sets the fully-qualified provider-defined type name.
+        /// </summary>
+        public string? Name { get; set; }
+
+        /// <summary>
+        /// Gets or sets the list of field names to include during dehydration.
+        /// If non-null and non-empty, only these fields are serialized.
+        /// Cannot be combined with <see cref="ExcludedFields"/>.
+        /// </summary>
+        public string[]? IncludedFields { get; set; }
+
+        /// <summary>
+        /// Gets or sets the list of field names to exclude during dehydration.
+        /// If non-null and non-empty, these fields are omitted from serialization.
+        /// Cannot be combined with <see cref="IncludedFields"/>.
+        /// </summary>
+        public string[]? ExcludedFields { get; set; }
+
+        /// <summary>
+        /// Static registry of annotated types keyed by PDT name, populated lazily during dehydration.
+        /// </summary>
+        internal static readonly ConcurrentDictionary<string, Type> RegisteredTypes = new();
+
+        /// <summary>
+        /// Hydrates a <see cref="ProviderDefinedType"/> using a registered annotated type.
+        /// Returns the original PDT if no annotated type is registered for the name or if hydration fails.
+        /// </summary>
+        internal static object HydrateIfRegistered(ProviderDefinedType pdt)
+        {
+            if (!RegisteredTypes.TryGetValue(pdt.Name, out var type))
+            {
+                // No registered type for outer — still recurse into nested PDT fields
+                Dictionary<string, object?>? resolved = null;
+                foreach (var (key, value) in pdt.Fields)
+                {
+                    if (value is ProviderDefinedType nested)
+                    {
+                        var hydrated = HydrateIfRegistered(nested);
+                        if (!ReferenceEquals(hydrated, nested))
+                        {
+                            resolved ??= new Dictionary<string, object?>(pdt.Fields);
+                            resolved[key] = hydrated;
+                        }
+                    }
+                }
+                return resolved != null ? new ProviderDefinedType(pdt.Name, resolved) : pdt;
+            }
+            try
+            {
+                var obj = Activator.CreateInstance(type)!;
+                foreach (var (key, value) in pdt.Fields)
+                {
+                    var prop = type.GetProperty(key, BindingFlags.Public | BindingFlags.Instance);
+                    if (prop != null && prop.CanWrite && value != null)
+                    {
+                        var resolved = value is ProviderDefinedType nested
+                            ? HydrateIfRegistered(nested)
+                            : value;
+                        prop.SetValue(obj, Convert.ChangeType(resolved, prop.PropertyType));
+                    }
+                }
+                return obj;
+            }
+            catch
+            {
+                return pdt;
+            }
+        }
+    }
+}
diff --git a/gremlin-dotnet/src/Gremlin.Net/Structure/ProviderDefinedType.cs b/gremlin-dotnet/src/Gremlin.Net/Structure/ProviderDefinedType.cs
new file mode 100644
index 0000000000..69c005c188
--- /dev/null
+++ b/gremlin-dotnet/src/Gremlin.Net/Structure/ProviderDefinedType.cs
@@ -0,0 +1,70 @@
+#region License
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#endregion
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Gremlin.Net.Structure
+{
+    /// <summary>
+    /// Represents a provider-defined type (PDT) with a name and a set of fields.
+    /// </summary>
+    public class ProviderDefinedType
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="ProviderDefinedType"/> class.
+        /// </summary>
+        /// <param name="name">The fully-qualified name of the provider-defined type.</param>
+        /// <param name="fields">The fields of the provider-defined type.</param>
+        public ProviderDefinedType(string name, IReadOnlyDictionary<string, object?> fields)
+        {
+            Name = name ?? throw new ArgumentNullException(nameof(name));
+            if (string.IsNullOrEmpty(name)) throw new ArgumentException("name cannot be empty", nameof(name));
+            Fields = fields ?? new Dictionary<string, object?>();
+        }
+
+        /// <summary>
+        /// Gets the fully-qualified name of this provider-defined type.
+        /// </summary>
+        public string Name { get; }
+
+        /// <summary>
+        /// Gets the fields of this provider-defined type.
+        /// </summary>
+        public IReadOnlyDictionary<string, object?> Fields { get; }
+
+        /// <inheritdoc />
+        public override string ToString() =>
+            $"pdt[{Name}]{{{string.Join(", ", Fields.Select(kv => $"{kv.Key}={kv.Value}"))}}}";
+
+        /// <inheritdoc />
+        public override bool Equals(object? obj) =>
+            obj is ProviderDefinedType other && Name == other.Name &&
+            Fields.Count == other.Fields.Count &&
+            Fields.All(kv => other.Fields.TryGetValue(kv.Key, out var v) && Equals(kv.Value, v));
+
+        /// <inheritdoc />
+        public override int GetHashCode() => HashCode.Combine(Name, Fields.Count);
+    }
+}
diff --git a/gremlin-dotnet/src/Gremlin.Net/Structure/ProviderDefinedTypeRegistry.cs b/gremlin-dotnet/src/Gremlin.Net/Structure/ProviderDefinedTypeRegistry.cs
new file mode 100644
index 0000000000..427a35ca44
--- /dev/null
+++ b/gremlin-dotnet/src/Gremlin.Net/Structure/ProviderDefinedTypeRegistry.cs
@@ -0,0 +1,159 @@
+#region License
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#endregion
+
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Reflection;
+
+namespace Gremlin.Net.Structure
+{
+    /// <summary>
+    /// Registry for <see cref="IProviderDefinedTypeAdapter{T}"/> instances that hydrate
+    /// <see cref="ProviderDefinedType"/> values into strongly-typed objects.
+    /// </summary>
+    public class ProviderDefinedTypeRegistry
+    {
+        private readonly Dictionary<string, object> _adaptersByName = new();
+        private readonly Dictionary<Type, (string typeName, object adapter)> _adaptersByType = new();
+
+        /// <summary>
+        /// Registers an adapter for a specific provider-defined type name.
+        /// </summary>
+        public void Register<T>(IProviderDefinedTypeAdapter<T> adapter)
+        {
+            _adaptersByName[adapter.TypeName] = adapter;
+            _adaptersByType[typeof(T)] = (adapter.TypeName, adapter);
+        }
+
+        /// <summary>
+        /// Creates a registry populated by scanning loaded assemblies for:
+        /// <list type="bullet">
+        /// <item>Types implementing <see cref="IProviderDefinedTypeAdapter{T}"/> (adapter-based hydration)</item>
+        /// <item>Types annotated with <see cref="ProviderDefinedAttribute"/> (annotation-based round-trip)</item>
+        /// </list>
+        /// </summary>
+        public static ProviderDefinedTypeRegistry Create()
+        {
+            var registry = new ProviderDefinedTypeRegistry();
+
+            foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
+            {
+                try
+                {
+                    foreach (var type in assembly.GetTypes())
+                    {
+                        // Register adapter implementations
+                        var adapterInterface = type.GetInterfaces()
+                            .FirstOrDefault(i => i.IsGenericType &&
+                                i.GetGenericTypeDefinition() == typeof(IProviderDefinedTypeAdapter<>));
+                        if (adapterInterface != null && !type.IsAbstract && !type.IsInterface)
+                        {
+                            try
+                            {
+                                var adapter = Activator.CreateInstance(type);
+                                var registerMethod = typeof(ProviderDefinedTypeRegistry)
+                                    .GetMethod(nameof(Register))!
+                                    .MakeGenericMethod(adapterInterface.GetGenericArguments()[0]);
+                                registerMethod.Invoke(registry, new[] { adapter });
+                            }
+                            catch
+                            {
+                                // skip types that can't be instantiated
+                            }
+                        }
+
+                        // Register annotated types for annotation-based round-trip hydration
+                        var pdtAttr = type.GetCustomAttribute<ProviderDefinedAttribute>();
+                        if (pdtAttr != null)
+                        {
+                            var typeName = !string.IsNullOrEmpty(pdtAttr.Name) ? pdtAttr.Name : type.Name;
+                            ProviderDefinedAttribute.RegisteredTypes.TryAdd(typeName, type);
+                        }
+                    }
+                }
+                catch
+                {
+                    // skip assemblies that can't be reflected
+                }
+            }
+
+            return registry;
+        }
+
+        /// <summary>
+        /// Returns the type name and ToFields method for the given CLR type, or null if not registered.
+        /// </summary>
+        internal (string typeName, Func<object, IReadOnlyDictionary<string, object?>>)? GetAdapterByType(Type type)
+        {
+            if (!_adaptersByType.TryGetValue(type, out var entry))
+                return null;
+            var method = entry.adapter.GetType().GetMethod("ToFields");
+            if (method == null) return null;
+            return (entry.typeName, obj => (IReadOnlyDictionary<string, object?>)method.Invoke(entry.adapter, new[] { obj })!);
+        }
+
+        /// <summary>
+        /// Hydrates a <see cref="ProviderDefinedType"/> into a typed object using a registered adapter.
+        /// Returns the original PDT if no adapter is registered or if hydration fails.
+        /// </summary>
+        public object Hydrate(ProviderDefinedType pdt)
+        {
+            if (!_adaptersByName.TryGetValue(pdt.Name, out var adapterObj))
+            {
+                // No adapter for outer — still recurse into nested PDT fields
+                Dictionary<string, object?>? resolved = null;
+                foreach (var (key, value) in pdt.Fields)
+                {
+                    if (value is ProviderDefinedType nested)
+                    {
+                        var hydrated = Hydrate(nested);
+                        if (!ReferenceEquals(hydrated, nested))
+                        {
+                            resolved ??= new Dictionary<string, object?>(pdt.Fields);
+                            resolved[key] = hydrated;
+                        }
+                    }
+                }
+                return resolved != null ? new ProviderDefinedType(pdt.Name, resolved) : pdt;
+            }
+            try
+            {
+                var hydratedFields = new Dictionary<string, object?>();
+                foreach (var (key, value) in pdt.Fields)
+                {
+                    hydratedFields[key] = value is ProviderDefinedType nested ? Hydrate(nested) : value;
+                }
+
+                var readOnlyFields = new ReadOnlyDictionary<string, object?>(hydratedFields);
+                var method = adapterObj.GetType().GetMethod("FromFields");
+                return method!.Invoke(adapterObj, new object[] { readOnlyFields })!;
+            }
+            catch (Exception)
+            {
+                return pdt;
+            }
+        }
+    }
+}
diff --git a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/DriverRemoteConnectionTests.cs b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/DriverRemoteConnectionTests.cs
index c34c232006..3aaedb218e 100644
--- a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/DriverRemoteConnectionTests.cs
+++ b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/DriverRemoteConnectionTests.cs
@@ -22,10 +22,14 @@
 #endregion
 
 using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
 using System.Threading.Tasks;
 using Gremlin.Net.Driver;
 using Gremlin.Net.Driver.Remote;
 using Gremlin.Net.Process.Traversal;
+using Gremlin.Net.Structure;
+using Gremlin.Net.Structure.IO.GraphBinary4;
 using Microsoft.Extensions.Logging;
 using NSubstitute;
 using Xunit;
@@ -92,4 +96,100 @@ public class DriverRemoteConnectionTests
             return gremlinLang;
         }
     }
+
+    [Fact]
+    public void ShouldRoundTripPdtViaTraversalApi()
+    {
+        var gremlinServer = new GremlinServer(TestHost, TestPort);
+        using var gremlinClient = new GremlinClient(gremlinServer);
+        using var connection = new DriverRemoteConnection(gremlinClient, "gmodern");
+        var g = AnonymousTraversalSource.Traversal().With(connection);
+
+        var pdt = new ProviderDefinedType("TestPoint",
+            new Dictionary<string, object?> { { "x", 1 }, { "y", 2 } });
+
+        var results = g.Inject<object>(pdt).ToList();
+
+        Assert.Single(results);
+        var result = Assert.IsType<ProviderDefinedType>(results[0]);
+        Assert.Equal("TestPoint", result.Name);
+        Assert.Equal(1, result.Fields["x"]);
+        Assert.Equal(2, result.Fields["y"]);
+    }
+
+    [Fact]
+    public void ShouldRoundTripTypedObjectViaRegistry()
+    {
+        var registry = new ProviderDefinedTypeRegistry();
+        registry.Register(new TestPointAdapter());
+
+        var gremlinServer = new GremlinServer(TestHost, TestPort);
+        using var gremlinClient = new GremlinClient(gremlinServer, pdtRegistry: registry);
+        using var connection = new DriverRemoteConnection(gremlinClient, "gmodern", pdtRegistry: registry);
+        var g = AnonymousTraversalSource.Traversal().With(connection);
+
+        var point = new TestPointClass { X = 5, Y = 10 };
+
+        var results = g.Inject<object>(point).ToList();
+
+        Assert.Single(results);
+        var result = Assert.IsType<TestPointClass>(results[0]);
+        Assert.Equal(5, result.X);
+        Assert.Equal(10, result.Y);
+    }
+
+    [Fact]
+    public void ShouldRoundTripAnnotatedClass()
+    {
+        var gremlinServer = new GremlinServer(TestHost, TestPort);
+        using var gremlinClient = new GremlinClient(gremlinServer);
+        using var connection = new DriverRemoteConnection(gremlinClient, "gmodern");
+        var g = AnonymousTraversalSource.Traversal().With(connection);
+
+        var point = new AnnotatedTestPoint { X = 3, Y = 7 };
+
+        var results = g.Inject<object>(point).ToList();
+
+        Assert.Single(results);
+        var result = Assert.IsType<AnnotatedTestPoint>(results[0]);
+        Assert.Equal(3, result.X);
+        Assert.Equal(7, result.Y);
+    }
+
+    #region Test helpers
+
+    private class TestPointClass
+    {
+        public int X { get; set; }
+        public int Y { get; set; }
+    }
+
+    private class TestPointAdapter : IProviderDefinedTypeAdapter<TestPointClass>
+    {
+        public string TypeName => "TestPoint";
+
+        public TestPointClass FromFields(IReadOnlyDictionary<string, object?> fields)
+        {
+            return new TestPointClass
+            {
+                X = Convert.ToInt32(fields["x"]),
+                Y = Convert.ToInt32(fields["y"])
+            };
+        }
+
+        public IReadOnlyDictionary<string, object?> ToFields(TestPointClass obj)
+        {
+            return new ReadOnlyDictionary<string, object?>(
+                new Dictionary<string, object?> { { "x", obj.X }, { "y", obj.Y } });
+        }
+    }
+
+    [ProviderDefined(Name = "TestPoint")]
+    private class AnnotatedTestPoint
+    {
+        public int X { get; set; }
+        public int Y { get; set; }
+    }
+
+    #endregion
 }
\ No newline at end of file
diff --git a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/GremlinClientTests.cs b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/GremlinClientTests.cs
index a1fb59efc7..a5fdac7c8e 100644
--- a/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/GremlinClientTests.cs
+++ b/gremlin-dotnet/test/Gremlin.Net.IntegrationTest/Driver/GremlinClientTests.cs
@@ -28,6 +28,7 @@ using Gremlin.Net.Driver;
 using Gremlin.Net.Driver.Exceptions;
 using Gremlin.Net.Driver.Messages;
 using Gremlin.Net.IntegrationTest.Util;
+using Gremlin.Net.Structure;
 using Microsoft.Extensions.Logging;
 using NSubstitute;
 using Xunit;
@@ -204,5 +205,72 @@ namespace Gremlin.Net.IntegrationTest.Driver
             
             logger.VerifyNothingWasLogged();
         }
+
+        [Fact]
+        public async Task ShouldRoundTripSimplePointPdt()
+        {
+            var gremlinServer = new GremlinServer(TestHost, TestPort);
+            using var gremlinClient = new GremlinClient(gremlinServer);
+
+            var response = await gremlinClient.SubmitAsync<object>(
+                "g.inject(PDT(\"Point\", [\"x\":1, \"y\":2]))");
+            var results = await response.ToListAsync();
+
+            Assert.Single(results);
+            var pdt = Assert.IsType<ProviderDefinedType>(results[0]);
+            Assert.Equal("Point", pdt.Name);
+            Assert.Equal(2, pdt.Fields.Count);
+            Assert.Equal(1, pdt.Fields["x"]);
+            Assert.Equal(2, pdt.Fields["y"]);
+        }
+
+        [Fact]
+        public async Task ShouldRoundTripNestedPdt()
+        {
+            var gremlinServer = new GremlinServer(TestHost, TestPort);
+            using var gremlinClient = new GremlinClient(gremlinServer);
+
+            var response = await gremlinClient.SubmitAsync<object>(
+                "g.inject(PDT(\"Person\", [\"name\":\"Alice\", \"age\":30, " +
+                "\"address\":PDT(\"Address\", [\"street\":\"123 Main St\", \"city\":\"Springfield\", \"zip\":\"12345\"])]))");
+            var results = await response.ToListAsync();
+
+            Assert.Single(results);
+            var pdt = Assert.IsType<ProviderDefinedType>(results[0]);
+            Assert.Equal("Person", pdt.Name);
+            Assert.Equal("Alice", pdt.Fields["name"]);
+            Assert.Equal(30, pdt.Fields["age"]);
+
+            var address = Assert.IsType<ProviderDefinedType>(pdt.Fields["address"]);
+            Assert.Equal("Address", address.Name);
+            Assert.Equal("123 Main St", address.Fields["street"]);
+            Assert.Equal("Springfield", address.Fields["city"]);
+            Assert.Equal("12345", address.Fields["zip"]);
+        }
+
+        [Fact]
+        public async Task ShouldHandlePdtInCollection()
+        {
+            var gremlinServer = new GremlinServer(TestHost, TestPort);
+            using var gremlinClient = new GremlinClient(gremlinServer);
+
+            var response = await gremlinClient.SubmitAsync<object>(
+                "g.inject([PDT(\"Point\", [\"x\":1, \"y\":2]), PDT(\"Point\", [\"x\":3, \"y\":4])])");
+            var results = await response.ToListAsync();
+
+            Assert.Single(results);
+            var list = Assert.IsType<List<object>>(results[0]);
+            Assert.Equal(2, list.Count);
+
+            var p1 = Assert.IsType<ProviderDefinedType>(list[0]);
+            Assert.Equal("Point", p1.Name);
+            Assert.Equal(1, p1.Fields["x"]);
+            Assert.Equal(2, p1.Fields["y"]);
+
+            var p2 = Assert.IsType<ProviderDefinedType>(list[1]);
+            Assert.Equal("Point", p2.Name);
+            Assert.Equal(3, p2.Fields["x"]);
+            Assert.Equal(4, p2.Fields["y"]);
+        }
     }
 }
diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Process/Traversal/GremlinLangTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Process/Traversal/GremlinLangTests.cs
index b4eeade168..daca1acc07 100644
--- a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Process/Traversal/GremlinLangTests.cs
+++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Process/Traversal/GremlinLangTests.cs
@@ -1088,5 +1088,148 @@ namespace Gremlin.Net.UnitTest.Process.Traversal
             var result = GremlinLang.ConvertParametersToString(parameters);
             Assert.Contains("\"name\":", result);
         }
+
+        [Fact]
+        public void g_Inject_PDT_basic()
+        {
+            var pdt = new ProviderDefinedType("Point", new Dictionary<string, object?> { { "x", 1 }, { "y", 2 } });
+            var result = _g.Inject((object)pdt).GremlinLang.GetGremlin();
+            Assert.Equal("g.inject(PDT(\"Point\",[\"x\":1,\"y\":2]))", result);
+        }
+
+        [Fact]
+        public void g_Inject_PDT_special_chars_in_name()
+        {
+            var pdt = new ProviderDefinedType("my\"type", new Dictionary<string, object?> { { "a", 1 } });
+            var result = _g.Inject((object)pdt).GremlinLang.GetGremlin();
+            Assert.Equal("g.inject(PDT(\"my\\\"type\",[\"a\":1]))", result);
+        }
+
+        [Fact]
+        public void g_Inject_PDT_nested()
+        {
+            var inner = new ProviderDefinedType("Inner", new Dictionary<string, object?> { { "v", 42 } });
+            var outer = new ProviderDefinedType("Outer", new Dictionary<string, object?> { { "child", inner } });
+            var result = _g.Inject((object)outer).GremlinLang.GetGremlin();
+            Assert.Equal("g.inject(PDT(\"Outer\",[\"child\":PDT(\"Inner\",[\"v\":42])]))", result);
+        }
+
+        [Fact]
+        public void g_Inject_PDT_auto_dehydration_via_attribute()
+        {
+            var point = new TestPoint { X = 10, Y = 20 };
+            var result = _g.Inject((object)point).GremlinLang.GetGremlin();
+            Assert.Equal("g.inject(PDT(\"geo.Point\",[\"X\":10,\"Y\":20]))", result);
+        }
+
+        [Fact]
+        public void g_Inject_PDT_auto_dehydration_IncludedFields()
+        {
+            var point = new IncludedFieldsPoint { X = 1, Y = 2, Z = 3 };
+            var result = _g.Inject((object)point).GremlinLang.GetGremlin();
+            Assert.Equal("g.inject(PDT(\"IncludedFieldsPoint\",[\"X\":1]))", result);
+        }
+
+        [Fact]
+        public void g_Inject_PDT_auto_dehydration_ExcludedFields()
+        {
+            var point = new ExcludedFieldsPoint { X = 1, Y = 2, Z = 3 };
+            var result = _g.Inject((object)point).GremlinLang.GetGremlin();
+            Assert.Equal("g.inject(PDT(\"ExcludedFieldsPoint\",[\"X\":1,\"Y\":2]))", result);
+        }
+
+        [Fact]
+        public void g_Inject_PDT_auto_dehydration_both_fields_throws()
+        {
+            var point = new BothFieldsPoint { X = 1, Y = 2 };
+            Assert.Throws<ArgumentException>(() => _g.Inject((object)point).GremlinLang.GetGremlin());
+        }
+
+        [ProviderDefined(Name = "geo.Point")]
+        private class TestPoint
+        {
+            public int X { get; set; }
+            public int Y { get; set; }
+        }
+
+        [ProviderDefined(IncludedFields = new[] { "X" })]
+        private class IncludedFieldsPoint
+        {
+            public int X { get; set; }
+            public int Y { get; set; }
+            public int Z { get; set; }
+        }
+
+        [ProviderDefined(ExcludedFields = new[] { "Z" })]
+        private class ExcludedFieldsPoint
+        {
+            public int X { get; set; }
+            public int Y { get; set; }
+            public int Z { get; set; }
+        }
+
+        [ProviderDefined(IncludedFields = new[] { "X" }, ExcludedFields = new[] { "Y" })]
+        private class BothFieldsPoint
+        {
+            public int X { get; set; }
+            public int Y { get; set; }
+        }
+
+        [Fact]
+        public void g_Inject_PDT_adapter_takes_precedence_over_attribute()
+        {
+            var registry = new ProviderDefinedTypeRegistry();
+            registry.Register(new AdapterForAnnotatedPoint());
+
+            var g = new GraphTraversalSource();
+            g.GremlinLang.PdtRegistry = registry;
+
+            var point = new AnnotatedPointWithAdapter { X = 5, Y = 10 };
+            var result = g.Inject((object)point).GremlinLang.GetGremlin();
+
+            // The adapter uses type name "adapter.Point" and only exposes "a"/"b" fields,
+            // overriding the attribute which would produce "attr.Point" with "X"/"Y".
+            Assert.Equal("g.inject(PDT(\"adapter.Point\",[\"a\":5,\"b\":10]))", result);
+        }
+
+        [ProviderDefined(Name = "attr.Point")]
+        private class AnnotatedPointWithAdapter
+        {
+            public int X { get; set; }
+            public int Y { get; set; }
+        }
+
+        private class AdapterForAnnotatedPoint : IProviderDefinedTypeAdapter<AnnotatedPointWithAdapter>
+        {
+            public string TypeName => "adapter.Point";
+
+            public AnnotatedPointWithAdapter FromFields(IReadOnlyDictionary<string, object?> fields) =>
+                new() { X = (int)fields["a"]!, Y = (int)fields["b"]! };
+
+            public IReadOnlyDictionary<string, object?> ToFields(AnnotatedPointWithAdapter obj) =>
+                new Dictionary<string, object?> { { "a", obj.X }, { "b", obj.Y } };
+        }
+
+        [Fact]
+        public void g_Inject_PDT_dehydrates_annotated_inner_nested_in_unregistered_outer()
+        {
+            // An unregistered outer PDT contains an annotated inner object as a field value.
+            // The inner should be dehydrated to its PDT form when the outer is serialized.
+            var inner = new NestedAnnotatedWidget { Tag = "abc" };
+            var outer = new ProviderDefinedType("unregistered:Container",
+                new Dictionary<string, object?> { { "widget", inner }, { "count", 7 } });
+
+            var result = _g.Inject((object)outer).GremlinLang.GetGremlin();
+
+            Assert.Equal(
+                "g.inject(PDT(\"unregistered:Container\",[\"widget\":PDT(\"nested.Widget\",[\"Tag\":\"abc\"]),\"count\":7]))",
+                result);
+        }
+
+        [ProviderDefined(Name = "nested.Widget")]
+        private class NestedAnnotatedWidget
+        {
+            public string Tag { get; set; } = "";
+        }
     }
 }
diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Structure/IO/GraphBinary4/ProviderDefinedTypeTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Structure/IO/GraphBinary4/ProviderDefinedTypeTests.cs
new file mode 100644
index 0000000000..6673cfdc82
--- /dev/null
+++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Structure/IO/GraphBinary4/ProviderDefinedTypeTests.cs
@@ -0,0 +1,137 @@
+#region License
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#endregion
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading.Tasks;
+using Gremlin.Net.Structure;
+using Gremlin.Net.Structure.IO.GraphBinary4;
+using Xunit;
+
+namespace Gremlin.Net.UnitTest.Structure.IO.GraphBinary4
+{
+    public class ProviderDefinedTypeTests
+    {
+        private static readonly GraphBinaryWriter Writer = new();
+        private static readonly GraphBinaryReader Reader = new();
+
+        [Fact]
+        public async Task TestRoundTripWithFields()
+        {
+            var fields = new Dictionary<string, object?> { { "x", 1 }, { "y", "hello" } };
+            var expected = new ProviderDefinedType("com.example.MyType", fields);
+
+            using var stream = new MemoryStream();
+            await Writer.WriteAsync(expected, stream);
+            stream.Position = 0;
+            var actual = await Reader.ReadAsync(stream) as ProviderDefinedType;
+
+            Assert.NotNull(actual);
+            Assert.Equal(expected.Name, actual!.Name);
+            Assert.Equal(expected.Fields, actual.Fields);
+        }
+
+        [Fact]
+        public async Task TestRoundTripWithEmptyFields()
+        {
+            var expected = new ProviderDefinedType("com.example.Empty", new Dictionary<string, object?>());
+
+            using var stream = new MemoryStream();
+            await Writer.WriteAsync(expected, stream);
+            stream.Position = 0;
+            var actual = await Reader.ReadAsync(stream) as ProviderDefinedType;
+
+            Assert.NotNull(actual);
+            Assert.Equal(expected.Name, actual!.Name);
+            Assert.Empty(actual.Fields);
+        }
+
+        [Fact]
+        public async Task TestRoundTripWithNullFieldValue()
+        {
+            var fields = new Dictionary<string, object?> { { "key", null } };
+            var expected = new ProviderDefinedType("com.example.NullVal", fields);
+
+            using var stream = new MemoryStream();
+            await Writer.WriteAsync(expected, stream);
+            stream.Position = 0;
+            var actual = await Reader.ReadAsync(stream) as ProviderDefinedType;
+
+            Assert.NotNull(actual);
+            Assert.Equal(expected.Name, actual!.Name);
+            Assert.Null(actual.Fields["key"]);
+        }
+
+        [Fact]
+        public async Task TestDataTypeCode()
+        {
+            var pdt = new ProviderDefinedType("com.example.Test", new Dictionary<string, object?>());
+
+            using var stream = new MemoryStream();
+            await Writer.WriteAsync(pdt, stream);
+
+            // First byte should be the CompositePDT type code 0xF0
+            Assert.Equal(0xF0, stream.ToArray()[0]);
+        }
+
+        [Fact]
+        public void TestConstructorThrowsOnNullName()
+        {
+            Assert.Throws<ArgumentNullException>(() =>
+                new ProviderDefinedType(null!, new Dictionary<string, object?>()));
+        }
+
+        [Fact]
+        public void TestConstructorThrowsOnEmptyName()
+        {
+            Assert.Throws<ArgumentException>(() =>
+                new ProviderDefinedType("", new Dictionary<string, object?>()));
+        }
+
+        [Fact]
+        public void TestEquality()
+        {
+            var a = new ProviderDefinedType("com.example.T", new Dictionary<string, object?> { { "k", 1 } });
+            var b = new ProviderDefinedType("com.example.T", new Dictionary<string, object?> { { "k", 1 } });
+            Assert.Equal(a, b);
+            Assert.Equal(a.GetHashCode(), b.GetHashCode());
+        }
+
+        [Fact]
+        public void TestInequality()
+        {
+            var a = new ProviderDefinedType("com.example.A", new Dictionary<string, object?>());
+            var b = new ProviderDefinedType("com.example.B", new Dictionary<string, object?>());
+            Assert.NotEqual(a, b);
+        }
+
+        [Fact]
+        public void TestToString()
+        {
+            var pdt = new ProviderDefinedType("com.example.T", new Dictionary<string, object?> { { "x", 42 } });
+            Assert.Contains("com.example.T", pdt.ToString());
+            Assert.Contains("x=42", pdt.ToString());
+        }
+    }
+}
diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Structure/ProviderDefinedAttributeTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Structure/ProviderDefinedAttributeTests.cs
new file mode 100644
index 0000000000..c039b5196e
--- /dev/null
+++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Structure/ProviderDefinedAttributeTests.cs
@@ -0,0 +1,216 @@
+#region License
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#endregion
+
+using System.Collections.Generic;
+using System.IO;
+using System.Threading.Tasks;
+using Gremlin.Net.Structure;
+using Gremlin.Net.Structure.IO.GraphBinary4;
+using Xunit;
+
+namespace Gremlin.Net.UnitTest.Structure
+{
+    public class ProviderDefinedAttributeTests
+    {
+        [Fact]
+        public void ShouldHydrateNestedAnnotatedPdt()
+        {
+            ProviderDefinedAttribute.RegisteredTypes["test:Address"] = typeof(TestAddress);
+            ProviderDefinedAttribute.RegisteredTypes["test:Person"] = typeof(TestPerson);
+            try
+            {
+                var addressPdt = new ProviderDefinedType("test:Address",
+                    new Dictionary<string, object?> { ["Street"] = "123 Main", ["City"] = "Springfield" });
+                var personPdt = new ProviderDefinedType("test:Person",
+                    new Dictionary<string, object?> { ["Name"] = "Alice", ["Address"] = addressPdt });
+
+                var result = ProviderDefinedAttribute.HydrateIfRegistered(personPdt);
+
+                var person = Assert.IsType<TestPerson>(result);
+                Assert.Equal("Alice", person.Name);
+                Assert.NotNull(person.Address);
+                Assert.Equal("123 Main", person.Address!.Street);
+                Assert.Equal("Springfield", person.Address.City);
+            }
+            finally
+            {
+                ProviderDefinedAttribute.RegisteredTypes.TryRemove("test:Address", out _);
+                ProviderDefinedAttribute.RegisteredTypes.TryRemove("test:Person", out _);
+            }
+        }
+
+        [Fact]
+        public void ShouldReturnRawPdtWhenFieldConversionFails()
+        {
+            ProviderDefinedAttribute.RegisteredTypes["test:Bad"] = typeof(TestBadTarget);
+            try
+            {
+                var pdt = new ProviderDefinedType("test:Bad",
+                    new Dictionary<string, object?> { ["Count"] = "not-an-int" });
+
+                var result = ProviderDefinedAttribute.HydrateIfRegistered(pdt);
+
+                Assert.Same(pdt, result);
+            }
+            finally
+            {
+                ProviderDefinedAttribute.RegisteredTypes.TryRemove("test:Bad", out _);
+            }
+        }
+
+        [Fact]
+        public void ShouldReturnRawPdtWhenNestedTypeNotRegistered()
+        {
+            ProviderDefinedAttribute.RegisteredTypes["test:Outer"] = typeof(TestOuter);
+            try
+            {
+                var nestedPdt = new ProviderDefinedType("test:Unknown",
+                    new Dictionary<string, object?> { ["X"] = 1 });
+                var outerPdt = new ProviderDefinedType("test:Outer",
+                    new Dictionary<string, object?> { ["Inner"] = nestedPdt });
+
+                // Inner is typed as TestInner but nested PDT can't hydrate — Convert.ChangeType
+                // would throw, but the try/catch returns raw pdt gracefully.
+                var result = ProviderDefinedAttribute.HydrateIfRegistered(outerPdt);
+
+                Assert.Same(outerPdt, result);
+            }
+            finally
+            {
+                ProviderDefinedAttribute.RegisteredTypes.TryRemove("test:Outer", out _);
+            }
+        }
+
+        [Fact]
+        public async Task ShouldNotThrowFromReaderWhenHydrationFails()
+        {
+            ProviderDefinedAttribute.RegisteredTypes["test:Bad"] = typeof(TestBadTarget);
+            try
+            {
+                var pdt = new ProviderDefinedType("test:Bad",
+                    new Dictionary<string, object?> { ["Count"] = "not-an-int" });
+
+                var writer = new GraphBinaryWriter();
+                var reader = new GraphBinaryReader();
+
+                using var stream = new MemoryStream();
+                await writer.WriteAsync(pdt, stream);
+                stream.Position = 0;
+                var result = await reader.ReadAsync(stream);
+
+                // Should return raw PDT, not throw
+                Assert.IsType<ProviderDefinedType>(result);
+            }
+            finally
+            {
+                ProviderDefinedAttribute.RegisteredTypes.TryRemove("test:Bad", out _);
+            }
+        }
+
+        [Fact]
+        public void ShouldReturnRawPdtWhenTypeNotRegistered()
+        {
+            var pdt = new ProviderDefinedType("test:Unregistered",
+                new Dictionary<string, object?> { ["X"] = 1 });
+
+            var result = ProviderDefinedAttribute.HydrateIfRegistered(pdt);
+
+            Assert.Same(pdt, result);
+        }
+
+        [Fact]
+        public void ShouldHydrateRegisteredInnerInsideUnregisteredOuter()
+        {
+            // Register ONLY the inner type, NOT the outer
+            ProviderDefinedAttribute.RegisteredTypes["test:InnerWidget"] = typeof(TestInnerWidget);
+            try
+            {
+                var innerPdt = new ProviderDefinedType("test:InnerWidget",
+                    new Dictionary<string, object?> { ["Label"] = "hello" });
+                var outerPdt = new ProviderDefinedType("test:UnregisteredOuter",
+                    new Dictionary<string, object?> { ["Widget"] = innerPdt, ["Count"] = 5 });
+
+                // Act
+                var result = ProviderDefinedAttribute.HydrateIfRegistered(outerPdt);
+
+                // Assert: outer is unregistered so stays raw PDT
+                var rawOuter = Assert.IsType<ProviderDefinedType>(result);
+                Assert.Equal("test:UnregisteredOuter", rawOuter.Name);
+
+                // The inner field SHOULD be hydrated because it IS registered
+                var widgetValue = rawOuter.Fields["Widget"];
+                var hydratedInner = Assert.IsType<TestInnerWidget>(widgetValue);
+                Assert.Equal("hello", hydratedInner.Label);
+
+                // Non-PDT fields unchanged
+                Assert.Equal(5, rawOuter.Fields["Count"]);
+            }
+            finally
+            {
+                ProviderDefinedAttribute.RegisteredTypes.TryRemove("test:InnerWidget", out _);
+            }
+        }
+
+        #region Test helpers
+
+        [ProviderDefined(Name = "test:Address")]
+        public class TestAddress
+        {
+            public string Street { get; set; } = "";
+            public string City { get; set; } = "";
+        }
+
+        [ProviderDefined(Name = "test:Person")]
+        public class TestPerson
+        {
+            public string Name { get; set; } = "";
+            public TestAddress? Address { get; set; }
+        }
+
+        [ProviderDefined(Name = "test:Bad")]
+        public class TestBadTarget
+        {
+            public int Count { get; set; }
+        }
+
+        [ProviderDefined(Name = "test:Outer")]
+        public class TestOuter
+        {
+            public TestInner? Inner { get; set; }
+        }
+
+        [ProviderDefined(Name = "test:Inner")]
+        public class TestInner
+        {
+            public int X { get; set; }
+        }
+
+        [ProviderDefined(Name = "test:InnerWidget")]
+        public class TestInnerWidget
+        {
+            public string Label { get; set; } = "";
+        }
+
+        #endregion
+    }
+}
diff --git a/gremlin-dotnet/test/Gremlin.Net.UnitTest/Structure/ProviderDefinedTypeRegistryTests.cs b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Structure/ProviderDefinedTypeRegistryTests.cs
new file mode 100644
index 0000000000..223a8dba70
--- /dev/null
+++ b/gremlin-dotnet/test/Gremlin.Net.UnitTest/Structure/ProviderDefinedTypeRegistryTests.cs
@@ -0,0 +1,242 @@
+#region License
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+#endregion
+
+using System;
+using System.Collections.Generic;
+using System.Reflection;
+using Gremlin.Net.Structure;
+using Xunit;
+
+namespace Gremlin.Net.UnitTest.Structure
+{
+    public class ProviderDefinedTypeRegistryTests
+    {
+        [Fact]
+        public void ShouldHydrateToTypedObjectWhenAdapterRegistered()
+        {
+            var registry = new ProviderDefinedTypeRegistry();
+            registry.Register(new PointAdapter());
+            var pdt = new ProviderDefinedType("geo:Point",
+                new Dictionary<string, object?> { ["x"] = 1.0, ["y"] = 2.0 });
+
+            var result = registry.Hydrate(pdt);
+
+            var point = Assert.IsType<Point>(result);
+            Assert.Equal(1.0, point.X);
+            Assert.Equal(2.0, point.Y);
+        }
+
+        [Fact]
+        public void ShouldReturnRawPdtWhenNoAdapterRegistered()
+        {
+            var registry = new ProviderDefinedTypeRegistry();
+            var pdt = new ProviderDefinedType("unknown:Type",
+                new Dictionary<string, object?> { ["a"] = "b" });
+
+            var result = registry.Hydrate(pdt);
+
+            Assert.Same(pdt, result);
+        }
+
+        [Fact]
+        public void ShouldReturnRawPdtWhenAdapterThrows()
+        {
+            var registry = new ProviderDefinedTypeRegistry();
+            registry.Register(new ThrowingAdapter());
+            var pdt = new ProviderDefinedType("bad:Type",
+                new Dictionary<string, object?> { ["x"] = "oops" });
+
+            var result = registry.Hydrate(pdt);
+
+            Assert.Same(pdt, result);
+        }
+
+        [Fact]
+        public void ShouldHydrateNestedPdt()
+        {
+            var registry = new ProviderDefinedTypeRegistry();
+            registry.Register(new PointAdapter());
+            registry.Register(new LineAdapter());
+            var startPdt = new ProviderDefinedType("geo:Point",
+                new Dictionary<string, object?> { ["x"] = 0.0, ["y"] = 0.0 });
+            var endPdt = new ProviderDefinedType("geo:Point",
+                new Dictionary<string, object?> { ["x"] = 3.0, ["y"] = 4.0 });
+            var linePdt = new ProviderDefinedType("geo:Line",
+                new Dictionary<string, object?> { ["start"] = startPdt, ["end"] = endPdt });
+
+            var result = registry.Hydrate(linePdt);
+
+            var line = Assert.IsType<Line>(result);
+            Assert.Equal(0.0, line.Start.X);
+            Assert.Equal(0.0, line.Start.Y);
+            Assert.Equal(3.0, line.End.X);
+            Assert.Equal(4.0, line.End.Y);
+        }
+
+        [Fact]
+        public void ShouldHaveProviderDefinedAttributeWithNameProperty()
+        {
+            var attr = typeof(AnnotatedPoint).GetCustomAttribute<ProviderDefinedAttribute>();
+
+            Assert.NotNull(attr);
+            Assert.Equal("geo:Point", attr!.Name);
+        }
+
+        [Fact]
+        public void CreateShouldReturnRegistryWithoutCrashing()
+        {
+            var registry = ProviderDefinedTypeRegistry.Create();
+
+            Assert.NotNull(registry);
+        }
+
+        [Fact]
+        public void CreateShouldDiscoverAdapterFromAssembly()
+        {
+            var registry = ProviderDefinedTypeRegistry.Create();
+            var pdt = new ProviderDefinedType("test:Discoverable",
+                new Dictionary<string, object?> { ["value"] = "hello" });
+
+            var result = registry.Hydrate(pdt);
+
+            var obj = Assert.IsType<DiscoverableType>(result);
+            Assert.Equal("hello", obj.Value);
+        }
+
+        [Fact]
+        public void ShouldHydrateRegisteredInnerInsideUnregisteredOuter()
+        {
+            // Contract: a registered/adapted inner PDT always hydrates even when nested inside an
+            // unregistered outer PDT. Register only the inner type adapter, NOT the outer.
+            var registry = new ProviderDefinedTypeRegistry();
+            registry.Register(new InnerPointAdapter());
+
+            var innerPdt = new ProviderDefinedType("nested:Point",
+                new Dictionary<string, object?> { ["x"] = 3.0, ["y"] = 4.0 });
+            var outerPdt = new ProviderDefinedType("unregistered:Container",
+                new Dictionary<string, object?> { ["location"] = innerPdt, ["label"] = "test" });
+
+            var result = registry.Hydrate(outerPdt);
+
+            // Outer stays raw PDT since it has no adapter
+            var rawOuter = Assert.IsType<ProviderDefinedType>(result);
+            Assert.Equal("unregistered:Container", rawOuter.Name);
+
+            // The inner field SHOULD be hydrated to InnerPoint because it IS registered
+            var locationValue = rawOuter.Fields["location"];
+            var hydratedInner = Assert.IsType<InnerPoint>(locationValue);
+            Assert.Equal(3.0, hydratedInner.X);
+            Assert.Equal(4.0, hydratedInner.Y);
+
+            // Non-PDT fields should be unchanged
+            Assert.Equal("test", rawOuter.Fields["label"]);
+        }
+
+        #region Test helpers
+
+        private class Point
+        {
+            public double X { get; init; }
+            public double Y { get; init; }
+        }
+
+        private class Line
+        {
+            public Point Start { get; init; } = null!;
+            public Point End { get; init; } = null!;
+        }
+
+        [ProviderDefined(Name = "geo:Point")]
+        private class AnnotatedPoint { }
+
+        private class PointAdapter : IProviderDefinedTypeAdapter<Point>
+        {
+            public string TypeName => "geo:Point";
+
+            public Point FromFields(IReadOnlyDictionary<string, object?> fields) =>
+                new() { X = (double)fields["x"]!, Y = (double)fields["y"]! };
+
+            public IReadOnlyDictionary<string, object?> ToFields(Point obj) =>
+                new Dictionary<string, object?> { ["x"] = obj.X, ["y"] = obj.Y };
+        }
+
+        private class LineAdapter : IProviderDefinedTypeAdapter<Line>
+        {
+            public string TypeName => "geo:Line";
+
+            public Line FromFields(IReadOnlyDictionary<string, object?> fields) =>
+                new() { Start = (Point)fields["start"]!, End = (Point)fields["end"]! };
+
+            public IReadOnlyDictionary<string, object?> ToFields(Line obj) =>
+                new Dictionary<string, object?> { ["start"] = obj.Start, ["end"] = obj.End };
+        }
+
+        private class ThrowingAdapter : IProviderDefinedTypeAdapter<object>
+        {
+            public string TypeName => "bad:Type";
+
+            public object FromFields(IReadOnlyDictionary<string, object?> fields) =>
+                throw new InvalidOperationException("intentional failure");
+
+            public IReadOnlyDictionary<string, object?> ToFields(object obj) =>
+                throw new InvalidOperationException("intentional failure");
+        }
+
+        private class InnerPoint
+        {
+            public double X { get; init; }
+            public double Y { get; init; }
+        }
+
+        private class InnerPointAdapter : IProviderDefinedTypeAdapter<InnerPoint>
+        {
+            public string TypeName => "nested:Point";
+
+            public InnerPoint FromFields(IReadOnlyDictionary<string, object?> fields) =>
+                new() { X = (double)fields["x"]!, Y = (double)fields["y"]! };
+
+            public IReadOnlyDictionary<string, object?> ToFields(InnerPoint obj) =>
+                new Dictionary<string, object?> { ["x"] = obj.X, ["y"] = obj.Y };
+        }
+
+        #endregion
+    }
+
+    /// <summary>Test type discoverable by Build() assembly scanning.</summary>
+    public class DiscoverableType
+    {
+        public string Value { get; init; } = "";
+    }
+
+    /// <summary>Test adapter discoverable by Build() assembly scanning.</summary>
+    public class DiscoverableTypeAdapter : IProviderDefinedTypeAdapter<DiscoverableType>
+    {
+        public string TypeName => "test:Discoverable";
+
+        public DiscoverableType FromFields(IReadOnlyDictionary<string, object?> fields) =>
+            new() { Value = (string)fields["value"]! };
+
+        public IReadOnlyDictionary<string, object?> ToFields(DiscoverableType obj) =>
+            new Dictionary<string, object?> { ["value"] = obj.Value };
+    }
+}
diff --git a/gremlin-driver/src/main/java/examples/Connections.java b/gremlin-driver/src/main/java/examples/Connections.java
index 42fe78da31..16ce3d0652 100644
--- a/gremlin-driver/src/main/java/examples/Connections.java
+++ b/gremlin-driver/src/main/java/examples/Connections.java
@@ -24,8 +24,6 @@ import org.apache.tinkerpop.gremlin.driver.Cluster;
 import org.apache.tinkerpop.gremlin.driver.remote.DriverRemoteConnection;
 import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource;
 import org.apache.tinkerpop.gremlin.structure.Graph;
-import org.apache.tinkerpop.gremlin.structure.io.AbstractIoRegistry;
-import org.apache.tinkerpop.gremlin.structure.io.IoRegistry;
 import org.apache.tinkerpop.gremlin.structure.io.binary.TypeSerializerRegistry;
 import org.apache.tinkerpop.gremlin.tinkergraph.structure.TinkerGraph;
 import org.apache.tinkerpop.gremlin.util.MessageSerializer;
@@ -93,8 +91,7 @@ public class Connections {
 
     // Connecting and specifying a serializer
     private static void withSerializer() throws Exception {
-        IoRegistry registry = new FakeIoRegistry(); // an IoRegistry instance exposed by a specific graph provider
-        TypeSerializerRegistry typeSerializerRegistry = TypeSerializerRegistry.build().addRegistry(registry).create();
+        TypeSerializerRegistry typeSerializerRegistry = TypeSerializerRegistry.build().create();
         MessageSerializer serializer = new GraphBinaryMessageSerializerV4(typeSerializerRegistry);
         Cluster cluster = Cluster.build(SERVER_HOST).
             port(SERVER_PORT).
@@ -110,6 +107,4 @@ public class Connections {
         cluster.close();
         g.close();
     }
-
-    public static class FakeIoRegistry extends AbstractIoRegistry {}
 }
diff --git a/gremlin-driver/src/main/java/org/apache/tinkerpop/gremlin/driver/remote/DriverRemoteConnection.java b/gremlin-driver/src/main/java/org/apache/tinkerpop/gremlin/driver/remote/DriverRemoteConnection.java
index baae303ce9..6c2fea1e71 100644
--- a/gremlin-driver/src/main/java/org/apache/tinkerpop/gremlin/driver/remote/DriverRemoteConnection.java
+++ b/gremlin-driver/src/main/java/org/apache/tinkerpop/gremlin/driver/remote/DriverRemoteConnection.java
@@ -27,6 +27,7 @@ import org.apache.tinkerpop.gremlin.process.remote.traversal.RemoteTraversal;
 import org.apache.tinkerpop.gremlin.process.traversal.GremlinLang;
 import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource;
 import org.apache.tinkerpop.gremlin.structure.Transaction;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeRegistry;
 import org.apache.tinkerpop.gremlin.util.iterator.IteratorUtils;
 
 import java.util.Optional;
@@ -55,6 +56,10 @@ public class DriverRemoteConnection implements RemoteConnection {
 
     private final boolean attachElements;
 
+    // Default to SPI-discovered adapters so PDT dehydration works with zero configuration.
+    // This performs a lightweight ServiceLoader scan; the serializer path does its own independent scan.
+    private ProviderDefinedTypeRegistry pdtRegistry = ProviderDefinedTypeRegistry.create();
+
     public DriverRemoteConnection(final Configuration conf) {
         final boolean hasClusterConf = IteratorUtils.anyMatch(conf.getKeys(), k -> k.startsWith("clusterConfiguration"));
         if (conf.containsKey(GREMLIN_REMOTE_DRIVER_CLUSTERFILE) && hasClusterConf)
@@ -214,6 +219,18 @@ public class DriverRemoteConnection implements RemoteConnection {
         }
     }
 
+    @Override
+    public ProviderDefinedTypeRegistry getPdtRegistry() {
+        return pdtRegistry;
+    }
+
+    /**
+     * Sets the {@link ProviderDefinedTypeRegistry} for registry-based dehydration in the gremlin-lang translator.
+     */
+    public void setPdtRegistry(final ProviderDefinedTypeRegistry pdtRegistry) {
+        this.pdtRegistry = pdtRegistry;
+    }
+
     @Override
     public <E> CompletableFuture<RemoteTraversal<?, E>> submitAsync(final GremlinLang gremlinLang) throws RemoteConnectionException {
         if (gremlinLang.containsUnsupportedTypes()) {
diff --git a/gremlin-examples/gremlin-java/Connections.java b/gremlin-examples/gremlin-java/Connections.java
index 8dd236a7f2..966fe6d81b 100644
--- a/gremlin-examples/gremlin-java/Connections.java
+++ b/gremlin-examples/gremlin-java/Connections.java
@@ -25,8 +25,6 @@ import org.apache.tinkerpop.gremlin.driver.Cluster;
 import org.apache.tinkerpop.gremlin.driver.remote.DriverRemoteConnection;
 import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource;
 import org.apache.tinkerpop.gremlin.structure.Graph;
-import org.apache.tinkerpop.gremlin.structure.io.AbstractIoRegistry;
-import org.apache.tinkerpop.gremlin.structure.io.IoRegistry;
 import org.apache.tinkerpop.gremlin.structure.io.binary.TypeSerializerRegistry;
 import org.apache.tinkerpop.gremlin.tinkergraph.structure.TinkerGraph;
 import org.apache.tinkerpop.gremlin.util.MessageSerializer;
@@ -96,8 +94,7 @@ public class Connections {
 
     // Connecting and specifying a serializer
     private static void withSerializer() throws Exception {
-        IoRegistry registry = new FakeIoRegistry(); // an IoRegistry instance exposed by a specific graph provider
-        TypeSerializerRegistry typeSerializerRegistry = TypeSerializerRegistry.build().addRegistry(registry).create();
+        TypeSerializerRegistry typeSerializerRegistry = TypeSerializerRegistry.build().create();
         MessageSerializer serializer = new GraphBinaryMessageSerializerV1(typeSerializerRegistry);
         Cluster cluster = Cluster.build(SERVER_HOST).
             port(SERVER_PORT).
@@ -113,6 +110,4 @@ public class Connections {
         cluster.close();
         g.close();
     }
-
-    public static class FakeIoRegistry extends AbstractIoRegistry {}
 }
diff --git a/gremlin-go/driver/client.go b/gremlin-go/driver/client.go
index 5e658d5ea1..ced49a1184 100644
--- a/gremlin-go/driver/client.go
+++ b/gremlin-go/driver/client.go
@@ -62,6 +62,9 @@ type ClientSettings struct {
 
 	EnableUserAgentOnConnect bool
 
+	// PDTRegistry enables automatic hydration of ProviderDefinedType values during deserialization.
+	PDTRegistry *PDTRegistry
+
 	// RequestInterceptors are functions that modify HTTP requests before sending.
 	RequestInterceptors []RequestInterceptor
 }
@@ -107,6 +110,7 @@ func NewClient(url string, configurations ...func(settings *ClientSettings)) (*C
 		keepAliveInterval:        settings.KeepAliveInterval,
 		enableCompression:        settings.EnableCompression,
 		enableUserAgentOnConnect: settings.EnableUserAgentOnConnect,
+		pdtRegistry:              settings.PDTRegistry,
 	}
 
 	logHandler := newLogHandler(settings.Logger, settings.LogVerbosity, settings.Language)
diff --git a/gremlin-go/driver/client_test.go b/gremlin-go/driver/client_test.go
index 9b201efb3f..ffffcad5cb 100644
--- a/gremlin-go/driver/client_test.go
+++ b/gremlin-go/driver/client_test.go
@@ -24,6 +24,7 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestClient(t *testing.T) {
@@ -293,3 +294,97 @@ func AssertMarkoVertexWithoutProperties(t *testing.T, result *Result) {
 	assert.True(t, ok)
 	assert.Equal(t, 0, len(properties))
 }
+
+func TestProviderDefinedTypeIntegration(t *testing.T) {
+	testNoAuthUrl := getEnvOrDefaultString("GREMLIN_SERVER_URL", noAuthUrl)
+	testNoAuthEnable := getEnvOrDefaultBool("RUN_INTEGRATION_TESTS", true)
+
+	t.Run("simple Point PDT round-trip", func(t *testing.T) {
+		skipTestsIfNotEnabled(t, integrationTestSuiteName, testNoAuthEnable)
+		client, err := NewClient(testNoAuthUrl, func(settings *ClientSettings) {
+			settings.TlsConfig = &tls.Config{}
+			settings.TraversalSource = testServerModernGraphAlias
+		})
+		require.NoError(t, err)
+		defer client.Close()
+
+		rs, err := client.Submit("g.inject(PDT(\"Point\", [\"x\":1, \"y\":2]))")
+		require.NoError(t, err)
+
+		result, ok, err := rs.One()
+		require.NoError(t, err)
+		require.True(t, ok)
+
+		pdt, ok := result.Data.(*ProviderDefinedType)
+		require.True(t, ok, "expected *ProviderDefinedType, got %T", result.Data)
+		assert.Equal(t, "Point", pdt.Name)
+		assert.Equal(t, int32(1), pdt.Fields["x"])
+		assert.Equal(t, int32(2), pdt.Fields["y"])
+	})
+
+	t.Run("nested PDT (Person with Address)", func(t *testing.T) {
+		skipTestsIfNotEnabled(t, integrationTestSuiteName, testNoAuthEnable)
+		client, err := NewClient(testNoAuthUrl, func(settings *ClientSettings) {
+			settings.TlsConfig = &tls.Config{}
+			settings.TraversalSource = testServerModernGraphAlias
+		})
+		require.NoError(t, err)
+		defer client.Close()
+
+		rs, err := client.Submit(
+			"g.inject(PDT(\"Person\", [\"name\":\"Alice\", \"age\":30, " +
+				"\"address\":PDT(\"Address\", [\"street\":\"123 Main St\", \"city\":\"Springfield\", \"zip\":\"12345\"])]))")
+		require.NoError(t, err)
+
+		result, ok, err := rs.One()
+		require.NoError(t, err)
+		require.True(t, ok)
+
+		pdt, ok := result.Data.(*ProviderDefinedType)
+		require.True(t, ok, "expected *ProviderDefinedType, got %T", result.Data)
+		assert.Equal(t, "Person", pdt.Name)
+		assert.Equal(t, "Alice", pdt.Fields["name"])
+		assert.Equal(t, int32(30), pdt.Fields["age"])
+
+		address, ok := pdt.Fields["address"].(*ProviderDefinedType)
+		require.True(t, ok, "expected nested *ProviderDefinedType for address")
+		assert.Equal(t, "Address", address.Name)
+		assert.Equal(t, "123 Main St", address.Fields["street"])
+		assert.Equal(t, "Springfield", address.Fields["city"])
+		assert.Equal(t, "12345", address.Fields["zip"])
+	})
+
+	t.Run("PDT in collection", func(t *testing.T) {
+		skipTestsIfNotEnabled(t, integrationTestSuiteName, testNoAuthEnable)
+		client, err := NewClient(testNoAuthUrl, func(settings *ClientSettings) {
+			settings.TlsConfig = &tls.Config{}
+			settings.TraversalSource = testServerModernGraphAlias
+		})
+		require.NoError(t, err)
+		defer client.Close()
+
+		rs, err := client.Submit(
+			"g.inject([PDT(\"Point\", [\"x\":1, \"y\":2]), PDT(\"Point\", [\"x\":3, \"y\":4])])")
+		require.NoError(t, err)
+
+		result, ok, err := rs.One()
+		require.NoError(t, err)
+		require.True(t, ok)
+
+		list, ok := result.Data.([]interface{})
+		require.True(t, ok, "expected []interface{}, got %T", result.Data)
+		require.Len(t, list, 2)
+
+		p1, ok := list[0].(*ProviderDefinedType)
+		require.True(t, ok)
+		assert.Equal(t, "Point", p1.Name)
+		assert.Equal(t, int32(1), p1.Fields["x"])
+		assert.Equal(t, int32(2), p1.Fields["y"])
+
+		p2, ok := list[1].(*ProviderDefinedType)
+		require.True(t, ok)
+		assert.Equal(t, "Point", p2.Name)
+		assert.Equal(t, int32(3), p2.Fields["x"])
+		assert.Equal(t, int32(4), p2.Fields["y"])
+	})
+}
\ No newline at end of file
diff --git a/gremlin-go/driver/connection.go b/gremlin-go/driver/connection.go
index ea0183626d..6a61500132 100644
--- a/gremlin-go/driver/connection.go
+++ b/gremlin-go/driver/connection.go
@@ -43,6 +43,7 @@ type connectionSettings struct {
 	keepAliveInterval        time.Duration
 	enableCompression        bool
 	enableUserAgentOnConnect bool
+	pdtRegistry              *PDTRegistry
 }
 
 // connection handles HTTP request/response for Gremlin queries.
@@ -313,7 +314,12 @@ func (c *connection) getReader(resp *http.Response) (io.Reader, io.Closer, error
 }
 
 func (c *connection) streamToResultSet(reader io.Reader, rs ResultSet) {
-	d := NewGraphBinaryDeserializer(reader)
+	var d *GraphBinaryDeserializer
+	if c.connSettings.pdtRegistry != nil {
+		d = NewGraphBinaryDeserializerWithRegistry(reader, c.connSettings.pdtRegistry)
+	} else {
+		d = NewGraphBinaryDeserializer(reader)
+	}
 	if err := d.ReadHeader(); err != nil {
 		if err != io.EOF {
 			c.logHandler.logf(Error, failedToReceiveResponse, err.Error())
diff --git a/gremlin-go/driver/driverRemoteConnection.go b/gremlin-go/driver/driverRemoteConnection.go
index 7239b8d5c4..a5a47a8c51 100644
--- a/gremlin-go/driver/driverRemoteConnection.go
+++ b/gremlin-go/driver/driverRemoteConnection.go
@@ -59,6 +59,9 @@ type DriverRemoteConnectionSettings struct {
 
 	// RequestInterceptors are functions that modify HTTP requests before sending.
 	RequestInterceptors []RequestInterceptor
+
+	// PDTRegistry enables registry-based dehydration in the gremlin-lang translator.
+	PDTRegistry *PDTRegistry
 }
 
 // DriverRemoteConnection is a remote connection.
@@ -103,6 +106,7 @@ func NewDriverRemoteConnection(
 		keepAliveInterval:        settings.KeepAliveInterval,
 		enableCompression:        settings.EnableCompression,
 		enableUserAgentOnConnect: settings.EnableUserAgentOnConnect,
+		pdtRegistry:              settings.PDTRegistry,
 	}
 
 	logHandler := newLogHandler(settings.Logger, settings.LogVerbosity, settings.Language)
diff --git a/gremlin-go/driver/error_codes.go b/gremlin-go/driver/error_codes.go
index 380e73664e..2d3c6b8917 100644
--- a/gremlin-go/driver/error_codes.go
+++ b/gremlin-go/driver/error_codes.go
@@ -54,7 +54,6 @@ const (
 	err0406EnumReaderInvalidTypeError                errorCode = "E0406_GRAPH_BINARY_ENUMREADER_INVALID_TYPE_ERROR"
 	err0407GetSerializerToWriteUnknownTypeError      errorCode = "E0407_GRAPH_BINARY_GETSERIALIZERTOWRITE_UNKNOWN_TYPE_ERROR"
 	err0408GetSerializerToReadUnknownTypeError       errorCode = "E0408_GRAPH_BINARY_GETSERIALIZERTOREAD_UNKNOWN_TYPE_ERROR"
-	err0409GetSerializerToReadUnknownCustomTypeError errorCode = "E0409_GRAPH_BINARY_GETSERIALIZERTOREAD_UNKNOWN_CUSTOM_TYPE_ERROR"
 
 	// response handling errors
 	err0501ResponseResultSetNotCreatedError errorCode = "E0501_RESPONSE_NO_RESULTSET_ON_DATA_RECEIVE"
diff --git a/gremlin-go/driver/graphBinaryDeserializer.go b/gremlin-go/driver/graphBinaryDeserializer.go
index f13acf9eac..6b06bc6e33 100644
--- a/gremlin-go/driver/graphBinaryDeserializer.go
+++ b/gremlin-go/driver/graphBinaryDeserializer.go
@@ -57,10 +57,11 @@ import (
 // The bufio.Reader wrapper provides efficient buffering without affecting the
 // streaming semantics - it simply reduces the number of underlying read syscalls.
 type GraphBinaryDeserializer struct {
-	r      *bufio.Reader
-	buf    [8]byte
-	err    error // sticky error
-	bulked bool  // whether the response stream uses bulked encoding
+	r           *bufio.Reader
+	buf         [8]byte
+	err         error        // sticky error
+	bulked      bool         // whether the response stream uses bulked encoding
+	pdtRegistry *PDTRegistry // optional: auto-hydrates ProviderDefinedType results
 }
 
 // GraphBinary flag for bulked list/set
@@ -72,6 +73,12 @@ func NewGraphBinaryDeserializer(r io.Reader) *GraphBinaryDeserializer {
 	return &GraphBinaryDeserializer{r: bufio.NewReaderSize(r, 8192)}
 }
 
+// NewGraphBinaryDeserializerWithRegistry creates a new GraphBinaryDeserializer with a PDTRegistry
+// for automatic hydration of ProviderDefinedType values.
+func NewGraphBinaryDeserializerWithRegistry(r io.Reader, registry *PDTRegistry) *GraphBinaryDeserializer {
+	return &GraphBinaryDeserializer{r: bufio.NewReaderSize(r, 8192), pdtRegistry: registry}
+}
+
 func (d *GraphBinaryDeserializer) readByte() (byte, error) {
 	if d.err != nil {
 		return 0, d.err
@@ -269,6 +276,8 @@ func (d *GraphBinaryDeserializer) readValue(dt dataType, flag byte) (interface{}
 		return d.readByteBuffer()
 	case tType, directionType, mergeType, gTypeType:
 		return d.readEnum(dt)
+	case compositePDTType:
+		return d.readCompositePDT()
 	default:
 		return nil, newError(err0408GetSerializerToReadUnknownTypeError, dt)
 	}
@@ -800,6 +809,40 @@ func (d *GraphBinaryDeserializer) readEnum(dt dataType) (interface{}, error) {
 	}
 }
 
+func (d *GraphBinaryDeserializer) readCompositePDT() (interface{}, error) {
+	nameObj, err := d.ReadFullyQualified()
+	if err != nil {
+		return nil, err
+	}
+	name, ok := nameObj.(string)
+	if !ok || name == "" {
+		return nil, fmt.Errorf("ProviderDefinedType name must be a non-empty string")
+	}
+	fieldsObj, err := d.ReadFullyQualified()
+	if err != nil {
+		return nil, err
+	}
+	var fields map[string]interface{}
+	if fieldsObj != nil {
+		raw, ok := fieldsObj.(map[interface{}]interface{})
+		if !ok {
+			return nil, fmt.Errorf("ProviderDefinedType fields must be a map")
+		}
+		fields = make(map[string]interface{}, len(raw))
+		for k, v := range raw {
+			fields[fmt.Sprint(k)] = v
+		}
+	}
+	pdt := &ProviderDefinedType{Name: name, Fields: fields}
+	if d.pdtRegistry != nil {
+		hydrated := d.pdtRegistry.Hydrate(pdt)
+		if hydrated != pdt {
+			return hydrated, nil
+		}
+	}
+	return pdt, nil
+}
+
 // ReadStatus reads the response status after the EndOfStream marker.
 // Returns the status code, message, exception string, and any error encountered.
 // This should be called after ReadFullyQualified() returns an EndOfStream marker.
diff --git a/gremlin-go/driver/graphBinarySerializer.go b/gremlin-go/driver/graphBinarySerializer.go
index f169b7fde6..c1ec27b674 100644
--- a/gremlin-go/driver/graphBinarySerializer.go
+++ b/gremlin-go/driver/graphBinarySerializer.go
@@ -37,7 +37,6 @@ type dataType uint8
 
 // dataType defined as constants.
 const (
-	customType         dataType = 0x00
 	intType            dataType = 0x01
 	longType           dataType = 0x02
 	stringType         dataType = 0x03
@@ -65,6 +64,7 @@ const (
 	mergeType          dataType = 0x2e
 	gTypeType          dataType = 0x30
 	durationType       dataType = 0x81
+	compositePDTType   dataType = 0xf0
 	markerType         dataType = 0xfd
 	nullType           dataType = 0xFE
 )
@@ -621,6 +621,8 @@ func (serializer *graphBinaryTypeSerializer) getType(val interface{}) (dataType,
 		return bigDecimalType, nil
 	case *ByteBuffer, ByteBuffer:
 		return byteBuffer, nil
+	case *ProviderDefinedType:
+		return compositePDTType, nil
 	default:
 		switch reflect.TypeOf(val).Kind() {
 		case reflect.Map:
diff --git a/gremlin-go/driver/graphBinarySerializer_test.go b/gremlin-go/driver/graphBinarySerializer_test.go
index fcf237147d..73b059d5c6 100644
--- a/gremlin-go/driver/graphBinarySerializer_test.go
+++ b/gremlin-go/driver/graphBinarySerializer_test.go
@@ -719,3 +719,104 @@ func TestWriterErrorPropagation(t *testing.T) {
 		assert.Equal(t, 22, w.written)
 	})
 }
+
+func TestProviderDefinedTypeSerialization(t *testing.T) {
+	serializer := graphBinaryTypeSerializer{newLogHandler(&defaultLogger{}, Error, language.English)}
+
+	t.Run("round-trip simple PDT", func(t *testing.T) {
+		source := &ProviderDefinedType{
+			Name:   "com.example.MyType",
+			Fields: map[string]interface{}{"key": "value", "num": int32(42)},
+		}
+		var buf bytes.Buffer
+		err := serializer.write(source, &buf)
+		assert.Nil(t, err)
+
+		d := NewGraphBinaryDeserializer(bytes.NewReader(buf.Bytes()))
+		result, err := d.ReadFullyQualified()
+		assert.Nil(t, err)
+		pdt, ok := result.(*ProviderDefinedType)
+		assert.True(t, ok)
+		assert.Equal(t, source.Name, pdt.Name)
+		assert.Equal(t, source.Fields["key"], pdt.Fields["key"])
+		assert.Equal(t, source.Fields["num"], pdt.Fields["num"])
+	})
+
+	t.Run("round-trip nested PDT", func(t *testing.T) {
+		inner := &ProviderDefinedType{
+			Name:   "com.example.Inner",
+			Fields: map[string]interface{}{"x": int32(1)},
+		}
+		outer := &ProviderDefinedType{
+			Name:   "com.example.Outer",
+			Fields: map[string]interface{}{"child": inner},
+		}
+		var buf bytes.Buffer
+		err := serializer.write(outer, &buf)
+		assert.Nil(t, err)
+
+		d := NewGraphBinaryDeserializer(bytes.NewReader(buf.Bytes()))
+		result, err := d.ReadFullyQualified()
+		assert.Nil(t, err)
+		pdt, ok := result.(*ProviderDefinedType)
+		assert.True(t, ok)
+		assert.Equal(t, "com.example.Outer", pdt.Name)
+		child, ok := pdt.Fields["child"].(*ProviderDefinedType)
+		assert.True(t, ok)
+		assert.Equal(t, "com.example.Inner", child.Name)
+		assert.Equal(t, int32(1), child.Fields["x"])
+	})
+
+	t.Run("empty name produces error", func(t *testing.T) {
+		data := []byte{
+			0xf0, 0x00,
+			0x03, 0x00, 0x00, 0x00, 0x00, 0x00,
+			0x0a, 0x00, 0x00, 0x00, 0x00, 0x00,
+		}
+		d := NewGraphBinaryDeserializer(bytes.NewReader(data))
+		_, err := d.ReadFullyQualified()
+		assert.NotNil(t, err)
+		assert.Contains(t, err.Error(), "non-empty")
+	})
+
+	t.Run("auto-hydrate with registry", func(t *testing.T) {
+		registry := NewPDTRegistry()
+		registry.RegisterFuncs("com.example.MyType",
+			func(fields map[string]interface{}) (interface{}, error) {
+				return map[string]interface{}{"hydrated": true, "key": fields["key"]}, nil
+			}, nil)
+
+		source := &ProviderDefinedType{
+			Name:   "com.example.MyType",
+			Fields: map[string]interface{}{"key": "value"},
+		}
+		var buf bytes.Buffer
+		err := serializer.write(source, &buf)
+		assert.Nil(t, err)
+
+		d := NewGraphBinaryDeserializerWithRegistry(bytes.NewReader(buf.Bytes()), registry)
+		result, err := d.ReadFullyQualified()
+		assert.Nil(t, err)
+		m, ok := result.(map[string]interface{})
+		assert.True(t, ok)
+		assert.Equal(t, true, m["hydrated"])
+		assert.Equal(t, "value", m["key"])
+	})
+
+	t.Run("no hydration without registry", func(t *testing.T) {
+		source := &ProviderDefinedType{
+			Name:   "com.example.MyType",
+			Fields: map[string]interface{}{"key": "value"},
+		}
+		var buf bytes.Buffer
+		err := serializer.write(source, &buf)
+		assert.Nil(t, err)
+
+		d := NewGraphBinaryDeserializer(bytes.NewReader(buf.Bytes()))
+		result, err := d.ReadFullyQualified()
+		assert.Nil(t, err)
+		pdt, ok := result.(*ProviderDefinedType)
+		assert.True(t, ok)
+		assert.Equal(t, "com.example.MyType", pdt.Name)
+	})
+}
diff --git a/gremlin-go/driver/graphTraversalSource.go b/gremlin-go/driver/graphTraversalSource.go
index c03fefa069..6cafdc81d7 100644
--- a/gremlin-go/driver/graphTraversalSource.go
+++ b/gremlin-go/driver/graphTraversalSource.go
@@ -48,6 +48,10 @@ func NewGraphTraversalSource(graph *Graph, remoteConnection remoteConnection,
 	traversalStrategies ...TraversalStrategy) *GraphTraversalSource {
 	// TODO: revisit when updating strategies
 	gl := NewGremlinLang(nil)
+	if drc, ok := remoteConnection.(*DriverRemoteConnection); ok && drc != nil &&
+		drc.settings != nil && drc.settings.PDTRegistry != nil {
+		gl.pdtRegistry = drc.settings.PDTRegistry
+	}
 	return &GraphTraversalSource{graph: graph, gremlinLang: gl, remoteConnection: remoteConnection}
 }
 
@@ -144,6 +148,9 @@ func (gts *GraphTraversalSource) With(key interface{}, value interface{}) *Graph
 // WithRemote adds a remote to be used throughout the life of a spawned Traversal.
 func (gts *GraphTraversalSource) WithRemote(remoteConnection *DriverRemoteConnection) *GraphTraversalSource {
 	gts.remoteConnection = remoteConnection
+	if remoteConnection != nil && remoteConnection.settings != nil && remoteConnection.settings.PDTRegistry != nil {
+		gts.gremlinLang.pdtRegistry = remoteConnection.settings.PDTRegistry
+	}
 	if gts.graphTraversal != nil {
 		gts.graphTraversal.remote = remoteConnection
 	}
diff --git a/gremlin-go/driver/gremlinlang.go b/gremlin-go/driver/gremlinlang.go
index 2625cb10e6..a08c6a7d31 100644
--- a/gremlin-go/driver/gremlinlang.go
+++ b/gremlin-go/driver/gremlinlang.go
@@ -26,6 +26,7 @@ import (
 	"math"
 	"math/big"
 	"reflect"
+	"sort"
 	"strconv"
 	"strings"
 	"time"
@@ -38,6 +39,7 @@ type GremlinLang struct {
 	gremlin           []string
 	parameters        map[string]interface{}
 	optionsStrategies []*traversalStrategy
+	pdtRegistry       *PDTRegistry
 }
 
 // NewGremlinLang creates a new GremlinLang to be used in traversals.
@@ -45,6 +47,7 @@ func NewGremlinLang(gl *GremlinLang) *GremlinLang {
 	gremlin := make([]string, 0)
 	parameters := make(map[string]interface{})
 	optionsStrategies := make([]*traversalStrategy, 0)
+	var registry *PDTRegistry
 	if gl != nil {
 		gremlin = make([]string, len(gl.gremlin))
 		copy(gremlin, gl.gremlin)
@@ -56,12 +59,14 @@ func NewGremlinLang(gl *GremlinLang) *GremlinLang {
 
 		optionsStrategies = make([]*traversalStrategy, len(gl.optionsStrategies))
 		copy(optionsStrategies, gl.optionsStrategies)
+		registry = gl.pdtRegistry
 	}
 
 	return &GremlinLang{
 		gremlin:           gremlin,
 		parameters:        parameters,
 		optionsStrategies: optionsStrategies,
+		pdtRegistry:       registry,
 	}
 }
 
@@ -200,6 +205,16 @@ func (gl *GremlinLang) argAsString(arg interface{}) (string, error) {
 	case dt:
 		name := reflect.ValueOf(v).Type().Name()
 		return fmt.Sprintf("%s.%s", strings.ToUpper(name), v), nil
+	case *ProviderDefinedType:
+		fields := v.Fields
+		if fields == nil {
+			fields = map[string]interface{}{}
+		}
+		mapStr, err := gl.translateMap(fields)
+		if err != nil {
+			return "", err
+		}
+		return fmt.Sprintf("PDT(\"%s\",%s)", escapeString(v.Name), mapStr), nil
 	case *Vertex:
 		return gl.argAsString(v.Id)
 	case textP:
@@ -284,6 +299,19 @@ func (gl *GremlinLang) argAsString(arg interface{}) (string, error) {
 	case []byte:
 		return fmt.Sprintf("Binary(\"%s\")", base64.StdEncoding.EncodeToString(v)), nil
 	default:
+		// Registry-based dehydration: a registered adapter intentionally takes precedence
+		// over any reflection/struct-based fallback, allowing explicit adapters to override
+		// default behavior for a given Go type.
+		if gl.pdtRegistry != nil {
+			adapter := gl.pdtRegistry.GetAdapterByType(reflect.TypeOf(arg))
+			if adapter != nil && adapter.ToFields != nil {
+				fields, err := adapter.ToFields(arg)
+				if err == nil {
+					pdt := &ProviderDefinedType{Name: adapter.TypeName, Fields: fields}
+					return gl.argAsString(pdt)
+				}
+			}
+		}
 		switch reflect.TypeOf(arg).Kind() {
 		case reflect.Map:
 			return gl.translateMap(arg)
@@ -308,14 +336,19 @@ func (gl *GremlinLang) translateMap(arg interface{}) (string, error) {
 	if size == 0 {
 		sb.WriteString(":")
 	} else {
-		iter := reflect.ValueOf(arg).MapRange()
-		for iter.Next() {
-			k := iter.Key().Interface()
+		mapVal := reflect.ValueOf(arg)
+		keys := mapVal.MapKeys()
+		// Sort keys for deterministic output (not semantic ordering)
+		sort.Slice(keys, func(i, j int) bool {
+			return fmt.Sprintf("%v", keys[i].Interface()) < fmt.Sprintf("%v", keys[j].Interface())
+		})
+		for idx, key := range keys {
+			k := key.Interface()
 			kString, err := gl.argAsString(k)
 			if err != nil {
 				return "", err
 			}
-			v := iter.Value().Interface()
+			v := mapVal.MapIndex(key).Interface()
 			vString, err := gl.argAsString(v)
 			if err != nil {
 				return "", err
@@ -323,8 +356,7 @@ func (gl *GremlinLang) translateMap(arg interface{}) (string, error) {
 			sb.WriteString(kString)
 			sb.WriteByte(':')
 			sb.WriteString(vString)
-			size--
-			if size > 0 {
+			if idx < len(keys)-1 {
 				sb.WriteString(",")
 			}
 		}
diff --git a/gremlin-go/driver/gremlinlang_test.go b/gremlin-go/driver/gremlinlang_test.go
index 719bd48f60..c025cc6d63 100644
--- a/gremlin-go/driver/gremlinlang_test.go
+++ b/gremlin-go/driver/gremlinlang_test.go
@@ -22,6 +22,7 @@ package gremlingo
 import (
 	"fmt"
 	"math"
+	"reflect"
 	"regexp"
 	"strings"
 	"testing"
@@ -858,3 +859,119 @@ func Test_ConvertParametersToString(t *testing.T) {
 		new(RequestOptionsBuilder).SetBindings(map[string]interface{}{"x": struct{}{}}).Create()
 	})
 }
+
+func Test_PDT_GremlinLang(t *testing.T) {
+	t.Run("basic PDT", func(t *testing.T) {
+		g := NewGraphTraversalSource(nil, nil)
+		pdt := &ProviderDefinedType{Name: "MyType", Fields: map[string]interface{}{"x": int32(1), "y": "hello"}}
+		gremlin := g.Inject(pdt).GremlinLang.GetGremlin()
+		expected := `g.inject(PDT("MyType",["x":1,"y":"hello"]))`
+		if gremlin != expected {
+			t.Errorf("got %v, expected %v", gremlin, expected)
+		}
+	})
+
+	t.Run("empty PDT", func(t *testing.T) {
+		g := NewGraphTraversalSource(nil, nil)
+		pdt := &ProviderDefinedType{Name: "Empty", Fields: map[string]interface{}{}}
+		gremlin := g.Inject(pdt).GremlinLang.GetGremlin()
+		expected := `g.inject(PDT("Empty",[:]))`
+		if gremlin != expected {
+			t.Errorf("got %v, expected %v", gremlin, expected)
+		}
+	})
+
+	t.Run("PDT with special characters in name", func(t *testing.T) {
+		g := NewGraphTraversalSource(nil, nil)
+		pdt := &ProviderDefinedType{Name: `say"hello"`, Fields: map[string]interface{}{"v": int32(1)}}
+		gremlin := g.Inject(pdt).GremlinLang.GetGremlin()
+		expected := `g.inject(PDT("say\"hello\"",["v":1]))`
+		if gremlin != expected {
+			t.Errorf("got %v, expected %v", gremlin, expected)
+		}
+	})
+
+	t.Run("PDT with backslash in name", func(t *testing.T) {
+		g := NewGraphTraversalSource(nil, nil)
+		pdt := &ProviderDefinedType{Name: `back\slash`, Fields: map[string]interface{}{"v": int32(1)}}
+		gremlin := g.Inject(pdt).GremlinLang.GetGremlin()
+		expected := `g.inject(PDT("back\\slash",["v":1]))`
+		if gremlin != expected {
+			t.Errorf("got %v, expected %v", gremlin, expected)
+		}
+	})
+
+	t.Run("nested PDT", func(t *testing.T) {
+		g := NewGraphTraversalSource(nil, nil)
+		inner := &ProviderDefinedType{Name: "Inner", Fields: map[string]interface{}{"v": int32(1)}}
+		outer := &ProviderDefinedType{Name: "Outer", Fields: map[string]interface{}{"inner": inner}}
+		gremlin := g.Inject(outer).GremlinLang.GetGremlin()
+		expected := `g.inject(PDT("Outer",["inner":PDT("Inner",["v":1])]))`
+		if gremlin != expected {
+			t.Errorf("got %v, expected %v", gremlin, expected)
+		}
+	})
+
+	t.Run("PDT map keys sorted", func(t *testing.T) {
+		g := NewGraphTraversalSource(nil, nil)
+		pdt := &ProviderDefinedType{Name: "T", Fields: map[string]interface{}{"z": int32(3), "a": int32(1), "m": int32(2)}}
+		gremlin := g.Inject(pdt).GremlinLang.GetGremlin()
+		expected := `g.inject(PDT("T",["a":1,"m":2,"z":3]))`
+		if gremlin != expected {
+			t.Errorf("got %v, expected %v", gremlin, expected)
+		}
+	})
+}
+
+// adapterPrecedencePoint is a struct type used to verify that a registered adapter
+// takes precedence over the default handling (which would panic for an unknown struct).
+type adapterPrecedencePoint struct {
+	X int32
+	Y int32
+}
+
+func Test_PDT_AdapterPrecedenceOverDefault(t *testing.T) {
+	registry := NewPDTRegistry()
+	registry.RegisterFuncsWithType("test:Point", reflect.TypeOf(adapterPrecedencePoint{}),
+		nil,
+		func(obj interface{}) (map[string]interface{}, error) {
+			p := obj.(adapterPrecedencePoint)
+			return map[string]interface{}{"x": p.X, "y": p.Y}, nil
+		})
+
+	g := NewGraphTraversalSource(nil, nil)
+	g.GetGremlinLang().pdtRegistry = registry
+
+	gremlin := g.Inject(adapterPrecedencePoint{X: 7, Y: 9}).GremlinLang.GetGremlin()
+	expected := `g.inject(PDT("test:Point",["x":7,"y":9]))`
+	if gremlin != expected {
+		t.Errorf("adapter precedence: got %v, expected %v", gremlin, expected)
+	}
+}
+
+// TestPDT_GremlinLang_NestedRegisteredInUnregisteredOuter asserts that a registered domain
+// object nested inside an unregistered outer *ProviderDefinedType is dehydrated to PDT form.
+func TestPDT_GremlinLang_NestedRegisteredInUnregisteredOuter(t *testing.T) {
+	registry := NewPDTRegistry()
+	registry.RegisterFuncsWithType("test:Point", reflect.TypeOf(adapterPrecedencePoint{}),
+		nil,
+		func(obj interface{}) (map[string]interface{}, error) {
+			p := obj.(adapterPrecedencePoint)
+			return map[string]interface{}{"x": p.X, "y": p.Y}, nil
+		})
+
+	g := NewGraphTraversalSource(nil, nil)
+	g.GetGremlinLang().pdtRegistry = registry
+
+	// Outer is an unregistered raw PDT; its "pt" field is a registered Go domain object.
+	outer := &ProviderDefinedType{
+		Name:   "unregistered:Wrapper",
+		Fields: map[string]interface{}{"pt": adapterPrecedencePoint{X: 3, Y: 4}, "tag": "hello"},
+	}
+
+	gremlin := g.Inject(outer).GremlinLang.GetGremlin()
+	expected := `g.inject(PDT("unregistered:Wrapper",["pt":PDT("test:Point",["x":3,"y":4]),"tag":"hello"]))`
+	if gremlin != expected {
+		t.Errorf("nested dehydration: got %v, expected %v", gremlin, expected)
+	}
+}
diff --git a/gremlin-go/driver/pdtRegistry.go b/gremlin-go/driver/pdtRegistry.go
new file mode 100644
index 0000000000..7b78f4f2f0
--- /dev/null
+++ b/gremlin-go/driver/pdtRegistry.go
@@ -0,0 +1,105 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+  http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+*/
+
+package gremlingo
+
+import "reflect"
+
+// PDTAdapter defines how to hydrate/dehydrate a provider-defined type.
+type PDTAdapter struct {
+	TypeName  string
+	FromFields func(map[string]interface{}) (interface{}, error)
+	ToFields   func(interface{}) (map[string]interface{}, error)
+}
+
+// PDTRegistry maps type names to their hydration adapters.
+type PDTRegistry struct {
+	adaptersByName map[string]*PDTAdapter
+	adaptersByType map[reflect.Type]*PDTAdapter
+}
+
+// NewPDTRegistry creates an empty PDTRegistry.
+func NewPDTRegistry() *PDTRegistry {
+	return &PDTRegistry{adaptersByName: make(map[string]*PDTAdapter), adaptersByType: make(map[reflect.Type]*PDTAdapter)}
+}
+
+// RegisterFuncs registers hydration/dehydration functions for a type name.
+func (r *PDTRegistry) RegisterFuncs(typeName string, fromFields func(map[string]interface{}) (interface{}, error), toFields func(interface{}) (map[string]interface{}, error)) {
+	adapter := &PDTAdapter{TypeName: typeName, FromFields: fromFields, ToFields: toFields}
+	r.adaptersByName[typeName] = adapter
+}
+
+// RegisterFuncsWithType registers hydration/dehydration functions for a type name and associates a Go type for dehydration lookup.
+func (r *PDTRegistry) RegisterFuncsWithType(typeName string, targetType reflect.Type, fromFields func(map[string]interface{}) (interface{}, error), toFields func(interface{}) (map[string]interface{}, error)) {
+	adapter := &PDTAdapter{TypeName: typeName, FromFields: fromFields, ToFields: toFields}
+	r.adaptersByName[typeName] = adapter
+	r.adaptersByType[targetType] = adapter
+}
+
+// RegisterType registers a struct type for reflection-based hydration using "pdt" struct tags.
+func (r *PDTRegistry) RegisterType(typeName string, targetType reflect.Type) {
+	r.adaptersByName[typeName] = &PDTAdapter{
+		TypeName: typeName,
+		FromFields: func(fields map[string]interface{}) (interface{}, error) {
+			obj := reflect.New(targetType).Elem()
+			for i := 0; i < targetType.NumField(); i++ {
+				field := targetType.Field(i)
+				tag := field.Tag.Get("pdt")
+				if tag == "" {
+					tag = field.Name
+				}
+				if val, ok := fields[tag]; ok && val != nil {
+					obj.Field(i).Set(reflect.ValueOf(val))
+				}
+			}
+			return obj.Interface(), nil
+		},
+	}
+}
+
+// GetAdapterByType returns the adapter registered for the given Go type, or nil.
+func (r *PDTRegistry) GetAdapterByType(t reflect.Type) *PDTAdapter {
+	return r.adaptersByType[t]
+}
+
+// Hydrate converts a ProviderDefinedType into a domain object using the registered adapter.
+// Returns the raw PDT if no adapter is found or if hydration fails.
+// Nested registered PDTs in Fields are always hydrated recursively, even when the outer has no adapter.
+func (r *PDTRegistry) Hydrate(pdt *ProviderDefinedType) interface{} {
+	if pdt == nil {
+		return nil
+	}
+	hydratedFields := make(map[string]interface{}, len(pdt.Fields))
+	for k, v := range pdt.Fields {
+		if nested, ok := v.(*ProviderDefinedType); ok {
+			hydratedFields[k] = r.Hydrate(nested)
+		} else {
+			hydratedFields[k] = v
+		}
+	}
+	adapter, ok := r.adaptersByName[pdt.Name]
+	if !ok {
+		return &ProviderDefinedType{Name: pdt.Name, Fields: hydratedFields}
+	}
+	result, err := adapter.FromFields(hydratedFields)
+	if err != nil {
+		return pdt
+	}
+	return result
+}
diff --git a/gremlin-go/driver/pdtRegistry_test.go b/gremlin-go/driver/pdtRegistry_test.go
new file mode 100644
index 0000000000..ff8baa8cf8
--- /dev/null
+++ b/gremlin-go/driver/pdtRegistry_test.go
@@ -0,0 +1,116 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+  http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+*/
+
+package gremlingo
+
+import (
+	"errors"
+	"reflect"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestPDTRegistryRegisterFuncsAndHydrate(t *testing.T) {
+	reg := NewPDTRegistry()
+	reg.RegisterFuncs("x:Point", func(fields map[string]interface{}) (interface{}, error) {
+		return [2]int{fields["x"].(int), fields["y"].(int)}, nil
+	}, nil)
+
+	pdt := &ProviderDefinedType{Name: "x:Point", Fields: map[string]interface{}{"x": 1, "y": 2}}
+	result := reg.Hydrate(pdt)
+	assert.Equal(t, [2]int{1, 2}, result)
+}
+
+func TestPDTRegistryNoAdapterReturnsRawPDT(t *testing.T) {
+	reg := NewPDTRegistry()
+	pdt := &ProviderDefinedType{Name: "x:Unknown", Fields: map[string]interface{}{"a": "b"}}
+	result := reg.Hydrate(pdt)
+	assert.Equal(t, pdt, result)
+}
+
+func TestPDTRegistryAdapterErrorReturnsRawPDT(t *testing.T) {
+	reg := NewPDTRegistry()
+	reg.RegisterFuncs("x:Bad", func(fields map[string]interface{}) (interface{}, error) {
+		return nil, errors.New("fail")
+	}, nil)
+
+	pdt := &ProviderDefinedType{Name: "x:Bad", Fields: map[string]interface{}{}}
+	result := reg.Hydrate(pdt)
+	assert.Equal(t, pdt, result)
+}
+
+func TestPDTRegistryNestedHydration(t *testing.T) {
+	reg := NewPDTRegistry()
+	reg.RegisterFuncs("x:Inner", func(fields map[string]interface{}) (interface{}, error) {
+		return fields["val"].(string) + "!", nil
+	}, nil)
+	reg.RegisterFuncs("x:Outer", func(fields map[string]interface{}) (interface{}, error) {
+		return "outer:" + fields["child"].(string), nil
+	}, nil)
+
+	inner := &ProviderDefinedType{Name: "x:Inner", Fields: map[string]interface{}{"val": "hi"}}
+	outer := &ProviderDefinedType{Name: "x:Outer", Fields: map[string]interface{}{"child": inner}}
+	result := reg.Hydrate(outer)
+	assert.Equal(t, "outer:hi!", result)
+}
+
+type testPoint struct {
+	X int    `pdt:"x"`
+	Y int    `pdt:"y"`
+	L string // no tag, uses field name
+}
+
+func TestPDTRegistryRegisterType(t *testing.T) {
+	reg := NewPDTRegistry()
+	reg.RegisterType("x:Point", reflect.TypeOf(testPoint{}))
+
+	pdt := &ProviderDefinedType{Name: "x:Point", Fields: map[string]interface{}{"x": 3, "y": 4, "L": "label"}}
+	result := reg.Hydrate(pdt)
+	assert.Equal(t, testPoint{X: 3, Y: 4, L: "label"}, result)
+}
+
+func TestPDTRegistryHydrateNil(t *testing.T) {
+	reg := NewPDTRegistry()
+	assert.Nil(t, reg.Hydrate(nil))
+}
+
+// TestPDTRegistryNestedHydration_UnregisteredOuter asserts that a registered inner PDT
+// is hydrated even when the outer PDT has no registered adapter (desired contract).
+func TestPDTRegistryNestedHydration_UnregisteredOuter(t *testing.T) {
+	reg := NewPDTRegistry()
+	reg.RegisterFuncs("x:Inner", func(fields map[string]interface{}) (interface{}, error) {
+		return [2]int{fields["x"].(int), fields["y"].(int)}, nil
+	}, nil)
+
+	inner := &ProviderDefinedType{Name: "x:Inner", Fields: map[string]interface{}{"x": 10, "y": 20}}
+	outer := &ProviderDefinedType{Name: "x:Unregistered", Fields: map[string]interface{}{"loc": inner, "label": "test"}}
+
+	result := reg.Hydrate(outer)
+
+	// The outer should remain a raw *ProviderDefinedType (no adapter registered for it).
+	pdt, ok := result.(*ProviderDefinedType)
+	assert.True(t, ok, "outer must remain *ProviderDefinedType, got %T", result)
+	assert.Equal(t, "x:Unregistered", pdt.Name)
+
+	// The inner field "loc" must be hydrated to [2]int{10,20} because x:Inner IS registered.
+	assert.Equal(t, [2]int{10, 20}, pdt.Fields["loc"], "inner registered PDT must be hydrated even inside unregistered outer")
+	// Non-PDT fields remain unchanged.
+	assert.Equal(t, "test", pdt.Fields["label"])
+}
diff --git a/gremlin-go/driver/providerDefinedType.go b/gremlin-go/driver/providerDefinedType.go
new file mode 100644
index 0000000000..2a5a359892
--- /dev/null
+++ b/gremlin-go/driver/providerDefinedType.go
@@ -0,0 +1,51 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+  http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+*/
+
+package gremlingo
+
+import (
+	"fmt"
+	"io"
+)
+
+// ProviderDefinedType represents a provider-defined type (PDT) in GraphBinary serialization.
+type ProviderDefinedType struct {
+	Name   string
+	Fields map[string]interface{}
+}
+
+func (p *ProviderDefinedType) String() string {
+	return fmt.Sprintf("pdt[%s]%v", p.Name, p.Fields)
+}
+
+// pdtWriter serializes a ProviderDefinedType as a fully-qualified string (name) followed by a fully-qualified map (fields).
+func pdtWriter(value interface{}, w io.Writer, typeSerializer *graphBinaryTypeSerializer) error {
+	pdt := value.(*ProviderDefinedType)
+	if err := typeSerializer.write(pdt.Name, w); err != nil {
+		return err
+	}
+	if pdt.Fields == nil {
+		return typeSerializer.write(map[interface{}]interface{}{}, w)
+	}
+	m := make(map[interface{}]interface{}, len(pdt.Fields))
+	for k, v := range pdt.Fields {
+		m[k] = v
+	}
+	return typeSerializer.write(m, w)
+}
\ No newline at end of file
diff --git a/gremlin-go/driver/providerDefinedType_test.go b/gremlin-go/driver/providerDefinedType_test.go
new file mode 100644
index 0000000000..8d0f394e24
--- /dev/null
+++ b/gremlin-go/driver/providerDefinedType_test.go
@@ -0,0 +1,36 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+  http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+*/
+
+package gremlingo
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestProviderDefinedType(t *testing.T) {
+	t.Run("String method", func(t *testing.T) {
+		pdt := &ProviderDefinedType{
+			Name:   "com.example.Test",
+			Fields: map[string]interface{}{"a": int32(1)},
+		}
+		assert.Contains(t, pdt.String(), "pdt[com.example.Test]")
+	})
+}
diff --git a/gremlin-go/driver/serializer.go b/gremlin-go/driver/serializer.go
index 7b2266db2c..38d618600c 100644
--- a/gremlin-go/driver/serializer.go
+++ b/gremlin-go/driver/serializer.go
@@ -24,7 +24,6 @@ import (
 	"encoding/binary"
 	"fmt"
 	"io"
-	"sync"
 )
 
 const graphBinaryMimeType = "application/vnd.graphbinary-v4.0"
@@ -40,22 +39,12 @@ type GraphBinarySerializer struct {
 	ser *graphBinaryTypeSerializer
 }
 
-// CustomTypeReader user provided function to deserialize custom types
-// Deprecated: Custom type deserialization is handled by GraphBinaryDeserializer
-type CustomTypeReader func(data *[]byte, i *int) (interface{}, error)
-
 type writer func(interface{}, io.Writer, *graphBinaryTypeSerializer) error
 
 var serializers map[dataType]writer
 
-// customTypeReaderLock used to synchronize access to the customDeserializers map
-// Deprecated: Custom type deserialization is handled by GraphBinaryDeserializer
-var customTypeReaderLock = sync.RWMutex{}
-var customDeserializers map[string]CustomTypeReader
-
 func init() {
 	initSerializers()
-	customDeserializers = map[string]CustomTypeReader{}
 }
 
 func newGraphBinarySerializer(handler *logHandler) *GraphBinarySerializer {
@@ -232,21 +221,8 @@ func initSerializers() {
 		setType:            setWriter,
 		byteBuffer:         byteBufferWriter,
 		markerType:         markerWriter,
+		compositePDTType:   pdtWriter,
 	}
 }
 
-// RegisterCustomTypeReader register a reader (deserializer) for a custom type
-// Deprecated: Custom type deserialization should be handled by extending GraphBinaryDeserializer
-func RegisterCustomTypeReader(customTypeName string, reader CustomTypeReader) {
-	customTypeReaderLock.Lock()
-	defer customTypeReaderLock.Unlock()
-	customDeserializers[customTypeName] = reader
-}
 
-// UnregisterCustomTypeReader unregister a reader (deserializer) for a custom type
-// Deprecated: Custom type deserialization should be handled by extending GraphBinaryDeserializer
-func UnregisterCustomTypeReader(customTypeName string) {
-	customTypeReaderLock.Lock()
-	defer customTypeReaderLock.Unlock()
-	delete(customDeserializers, customTypeName)
-}
diff --git a/gremlin-go/driver/traversal_test.go b/gremlin-go/driver/traversal_test.go
index 83c264ec33..40a20d2904 100644
--- a/gremlin-go/driver/traversal_test.go
+++ b/gremlin-go/driver/traversal_test.go
@@ -26,6 +26,7 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestTraversal(t *testing.T) {
@@ -508,3 +509,72 @@ func getCount(t *testing.T, g *GraphTraversalSource) int32 {
 	assert.Nil(t, err)
 	return val
 }
+
+func TestProviderDefinedTypeTraversalAPIIntegration(t *testing.T) {
+	testNoAuthUrl := getEnvOrDefaultString("GREMLIN_SERVER_URL", noAuthUrl)
+	testNoAuthEnable := getEnvOrDefaultBool("RUN_INTEGRATION_TESTS", true)
+
+	t.Run("raw PDT round-trip via Traversal API", func(t *testing.T) {
+		skipTestsIfNotEnabled(t, integrationTestSuiteName, testNoAuthEnable)
+		remote, err := NewDriverRemoteConnection(testNoAuthUrl,
+			func(settings *DriverRemoteConnectionSettings) {
+				settings.TlsConfig = &tls.Config{}
+				settings.TraversalSource = testServerModernGraphAlias
+			})
+		require.NoError(t, err)
+		defer remote.Close()
+
+		g := Traversal_().With(remote)
+		pdt := &ProviderDefinedType{Name: "TestPoint", Fields: map[string]interface{}{"x": int32(1), "y": int32(2)}}
+
+		results, err := g.Inject(pdt).ToList()
+		require.NoError(t, err)
+		require.Len(t, results, 1)
+
+		result, ok := results[0].GetInterface().(*ProviderDefinedType)
+		require.True(t, ok, "expected *ProviderDefinedType, got %T", results[0].GetInterface())
+		assert.Equal(t, "TestPoint", result.Name)
+		assert.Equal(t, int32(1), result.Fields["x"])
+		assert.Equal(t, int32(2), result.Fields["y"])
+	})
+
+	t.Run("registry-based round-trip via typed struct", func(t *testing.T) {
+		skipTestsIfNotEnabled(t, integrationTestSuiteName, testNoAuthEnable)
+		registry := NewPDTRegistry()
+		registry.RegisterFuncsWithType("RegPoint", reflect.TypeOf(regPoint{}),
+			func(fields map[string]interface{}) (interface{}, error) {
+				return &regPoint{X: fields["x"].(int32), Y: fields["y"].(int32)}, nil
+			},
+			func(obj interface{}) (map[string]interface{}, error) {
+				p := obj.(regPoint)
+				return map[string]interface{}{"x": p.X, "y": p.Y}, nil
+			})
+
+		remote, err := NewDriverRemoteConnection(testNoAuthUrl,
+			func(settings *DriverRemoteConnectionSettings) {
+				settings.TlsConfig = &tls.Config{}
+				settings.TraversalSource = testServerModernGraphAlias
+				settings.PDTRegistry = registry
+			})
+		require.NoError(t, err)
+		defer remote.Close()
+
+		g := Traversal_().With(remote)
+		point := regPoint{X: 5, Y: 10}
+
+		results, err := g.Inject(point).ToList()
+		require.NoError(t, err)
+		require.Len(t, results, 1)
+
+		result, ok := results[0].GetInterface().(*regPoint)
+		require.True(t, ok, "expected *regPoint, got %T", results[0].GetInterface())
+		assert.Equal(t, int32(5), result.X)
+		assert.Equal(t, int32(10), result.Y)
+	})
+}
+
+// regPoint is a plain struct used for registry-based tests.
+type regPoint struct {
+	X int32
+	Y int32
+}
\ No newline at end of file
diff --git a/gremlin-js/gremlin-javascript/lib/driver/connection.ts b/gremlin-js/gremlin-javascript/lib/driver/connection.ts
index ef57960b73..fb91060af3 100644
--- a/gremlin-js/gremlin-javascript/lib/driver/connection.ts
+++ b/gremlin-js/gremlin-javascript/lib/driver/connection.ts
@@ -25,6 +25,7 @@ import { Buffer } from 'buffer';
 import { EventEmitter } from 'eventemitter3';
 import type { Agent } from 'node:http';
 import ioc, { createPreciseReader } from '../structure/io/binary/GraphBinary.js';
+import GraphBinaryReader from '../structure/io/binary/internals/GraphBinaryReader.js';
 import StreamReader from '../structure/io/binary/internals/StreamReader.js';
 import * as utils from '../utils.js';
 import ResultSet from './result-set.js';
@@ -32,7 +33,7 @@ import {RequestMessage} from "./request-message.js";
 import ResponseError from './response-error.js';
 import { Traverser } from '../process/traversal.js';
 
-const { graphBinaryReader, graphBinaryWriter } = ioc;
+const { graphBinaryWriter } = ioc;
 
 const responseStatusCode = {
   success: 200,
@@ -54,6 +55,7 @@ export type ConnectionOptions = {
   cert?: string | string[] | Buffer;
   pfx?: string | Buffer;
   preciseNumbers?: boolean;
+  pdtRegistry?: any;
   reader?: any;
   rejectUnauthorized?: boolean;
   traversalSource?: string;
@@ -88,8 +90,11 @@ export default class Connection extends EventEmitter {
   ) {
     super();
 
-    this._reader = options.reader || (options.preciseNumbers === true ? createPreciseReader() : graphBinaryReader);
+    this._reader = options.reader || (options.preciseNumbers === true ? createPreciseReader() : new GraphBinaryReader(ioc));
     this._writer = 'writer' in options ? options.writer : graphBinaryWriter;
+    if (options.pdtRegistry) {
+      this._reader.pdtRegistry = options.pdtRegistry;
+    }
     this.traversalSource = options.traversalSource || 'g';
     this._enableUserAgentOnConnect = options.enableUserAgentOnConnect !== false;
 
diff --git a/gremlin-js/gremlin-javascript/lib/driver/remote-connection.ts b/gremlin-js/gremlin-javascript/lib/driver/remote-connection.ts
index 6edd36acc2..5559edc01f 100644
--- a/gremlin-js/gremlin-javascript/lib/driver/remote-connection.ts
+++ b/gremlin-js/gremlin-javascript/lib/driver/remote-connection.ts
@@ -37,7 +37,7 @@ export abstract class RemoteConnection {
    */
   constructor(
     public url: string,
-    protected readonly options: ConnectionOptions = {},
+    public readonly options: ConnectionOptions = {},
   ) {}
 
   /**
diff --git a/gremlin-js/gremlin-javascript/lib/index.ts b/gremlin-js/gremlin-javascript/lib/index.ts
index f79c31e948..ef0e7ce133 100644
--- a/gremlin-js/gremlin-javascript/lib/index.ts
+++ b/gremlin-js/gremlin-javascript/lib/index.ts
@@ -25,6 +25,7 @@ import * as t from './process/traversal.js';
 import * as gt from './process/graph-traversal.js';
 import * as strategiesModule from './process/traversal-strategy.js';
 import * as graph from './structure/graph.js';
+import { ProviderDefinedTypeRegistry } from './structure/ProviderDefinedTypeRegistry.js';
 import * as rc from './driver/remote-connection.js';
 import GremlinLang from './process/gremlin-lang.js';
 import * as utils from './utils.js';
@@ -84,6 +85,8 @@ export const structure = {
   Graph: graph.Graph,
   Path: graph.Path,
   Property: graph.Property,
+  ProviderDefinedType: graph.ProviderDefinedType,
+  ProviderDefinedTypeRegistry,
   Vertex: graph.Vertex,
   VertexProperty: graph.VertexProperty,
   toLong: utils.toLong,
diff --git a/gremlin-js/gremlin-javascript/lib/process/anonymous-traversal.ts b/gremlin-js/gremlin-javascript/lib/process/anonymous-traversal.ts
index 3432735ac3..5920c48dc9 100644
--- a/gremlin-js/gremlin-javascript/lib/process/anonymous-traversal.ts
+++ b/gremlin-js/gremlin-javascript/lib/process/anonymous-traversal.ts
@@ -59,10 +59,14 @@ export default class AnonymousTraversalSource {
   with_(connection: RemoteConnection) {
     const traversalStrategies = new TraversalStrategies();
     traversalStrategies.addStrategy(new RemoteStrategy(connection));
+    const gl = new GremlinLang();
+    if (connection.options?.pdtRegistry) {
+      gl.pdtRegistry = connection.options.pdtRegistry;
+    }
     return new this.traversalSourceClass!(
       new Graph(),
       traversalStrategies,
-      new GremlinLang(),
+      gl,
       this.traversalSourceClass,
       this.traversalClass,
     );
diff --git a/gremlin-js/gremlin-javascript/lib/process/gremlin-lang.ts b/gremlin-js/gremlin-javascript/lib/process/gremlin-lang.ts
index 2a07ad8dec..070a88fceb 100644
--- a/gremlin-js/gremlin-javascript/lib/process/gremlin-lang.ts
+++ b/gremlin-js/gremlin-javascript/lib/process/gremlin-lang.ts
@@ -20,19 +20,22 @@
 import { P, TextP, EnumValue } from './traversal.js';
 import { OptionsStrategy, TraversalStrategy } from './traversal-strategy.js';
 import { Long, Int, Float, Double, Short, Byte, INT32_MIN, INT32_MAX } from '../utils.js';
-import { Vertex } from '../structure/graph.js';
+import { Vertex, ProviderDefinedType } from '../structure/graph.js';
+import { ProviderDefinedTypeRegistry } from '../structure/ProviderDefinedTypeRegistry.js';
 import { Buffer } from 'buffer';
 
 export default class GremlinLang {
   private gremlin: string = '';
   private optionsStrategies: OptionsStrategy[] = [];
   private parameters: Map<string, any> = new Map();
+  pdtRegistry: ProviderDefinedTypeRegistry | null = null;
   
   constructor(toClone?: GremlinLang) {
     if (toClone) {
       this.gremlin = toClone.gremlin;
       this.optionsStrategies = [...toClone.optionsStrategies];
       this.parameters = new Map(toClone.parameters);
+      this.pdtRegistry = toClone.pdtRegistry;
     }
   }
   
@@ -128,6 +131,14 @@ export default class GremlinLang {
     if (typeof arg === 'function' && arg.prototype instanceof TraversalStrategy) {
       return arg.name;
     }
+    if (arg instanceof ProviderDefinedType) {
+      const fields = arg.fields;
+      const keys = Object.keys(fields);
+      const escapedName = JSON.stringify(arg.name).slice(1, -1);
+      if (keys.length === 0) return `PDT("${escapedName}",[:])`; 
+      const entries = keys.map(k => `${this._argAsString(k)}:${this._argAsString(fields[k])}`);
+      return `PDT("${escapedName}",[${entries.join(',')}])`;
+    }
     if (arg instanceof Vertex) {
       return this._argAsString(arg.id);
     }
@@ -167,6 +178,14 @@ export default class GremlinLang {
       if (entries.length === 0) return '[:]';
       return '[' + entries.map(([k, v]) => `${this._argAsString(k)}:${this._argAsString(v)}`).join(',') + ']';
     }
+    // Registry-based dehydration
+    if (this.pdtRegistry && typeof arg === 'object' && arg.constructor) {
+      const entry = this.pdtRegistry.getAdapterByClass(arg.constructor);
+      if (entry) {
+        const fields = entry.serialize(arg);
+        return this._argAsString(new ProviderDefinedType(entry.typeName, fields));
+      }
+    }
     throw new TypeError(`GremlinLang contains at least one type [${arg?.constructor?.name ?? typeof arg}] that cannot be represented as text.`);
   }
   
diff --git a/gremlin-js/gremlin-javascript/lib/structure/ProviderDefinedTypeRegistry.ts b/gremlin-js/gremlin-javascript/lib/structure/ProviderDefinedTypeRegistry.ts
new file mode 100644
index 0000000000..7c560147a6
--- /dev/null
+++ b/gremlin-js/gremlin-javascript/lib/structure/ProviderDefinedTypeRegistry.ts
@@ -0,0 +1,81 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+import { ProviderDefinedType } from './graph.js';
+
+export interface PdtAdapter {
+  serialize: (obj: any) => Record<string, any>;
+  deserialize: (fields: Record<string, any>) => any;
+}
+
+/**
+ * A standalone registry that allows users to register adapters for hydrating
+ * raw {@link ProviderDefinedType} instances into domain-specific objects.
+ */
+export class ProviderDefinedTypeRegistry {
+  private readonly _adapters: Map<string, PdtAdapter> = new Map();
+  private readonly _adaptersByClass: Map<Function, { typeName: string; adapter: PdtAdapter }> = new Map();
+
+  register(typeName: string, adapter: PdtAdapter, targetClass?: Function): void {
+    this._adapters.set(typeName, adapter);
+    if (targetClass) {
+      this._adaptersByClass.set(targetClass, { typeName, adapter });
+    }
+  }
+
+  hydrate(pdt: any): any {
+    if (!(pdt instanceof ProviderDefinedType)) return pdt;
+    const adapter = this._adapters.get(pdt.name);
+    const hydratedFields: Record<string, any> = {};
+    let changed = false;
+    for (const [k, v] of Object.entries(pdt.fields)) {
+      if (v instanceof ProviderDefinedType) {
+        const h = this.hydrate(v);
+        hydratedFields[k] = h;
+        if (h !== v) changed = true;
+      } else {
+        hydratedFields[k] = v;
+      }
+    }
+    if (!adapter) {
+      return changed ? new ProviderDefinedType(pdt.name, hydratedFields) : pdt;
+    }
+    try {
+      return adapter.deserialize(hydratedFields);
+    } catch (e: any) {
+      console.warn(`PDT hydration failed for '${pdt.name}': ${e.message}`);
+      return pdt;
+    }
+  }
+
+  hasAdapter(typeName: string): boolean {
+    return this._adapters.has(typeName);
+  }
+
+  getSerializer(typeName: string): ((obj: any) => Record<string, any>) | null {
+    const adapter = this._adapters.get(typeName);
+    return adapter ? adapter.serialize : null;
+  }
+
+  getAdapterByClass(cls: Function): { typeName: string; serialize: (obj: any) => Record<string, any> } | null {
+    const entry = this._adaptersByClass.get(cls);
+    if (!entry) return null;
+    return { typeName: entry.typeName, serialize: entry.adapter.serialize };
+  }
+}
diff --git a/gremlin-js/gremlin-javascript/lib/structure/graph.ts b/gremlin-js/gremlin-javascript/lib/structure/graph.ts
index 8b91828512..75baa70014 100644
--- a/gremlin-js/gremlin-javascript/lib/structure/graph.ts
+++ b/gremlin-js/gremlin-javascript/lib/structure/graph.ts
@@ -196,6 +196,24 @@ function areEqual(obj1: any, obj2: any) {
   return false;
 }
 
+/**
+ * Represents a composite Provider Defined Type (PDT).
+ */
+export class ProviderDefinedType {
+  readonly name: string;
+  readonly fields: Readonly<Record<string, any>>;
+
+  constructor(name: string, fields?: Record<string, any>) {
+    if (!name) throw new Error('ProviderDefinedType name cannot be null or empty');
+    this.name = name;
+    this.fields = Object.freeze(fields || {});
+  }
+
+  toString() {
+    return `pdt[${this.name}]${JSON.stringify(this.fields)}`;
+  }
+}
+
 function summarize(value: any) {
   if (value === null || value === undefined) {
     return value;
diff --git a/gremlin-js/gremlin-javascript/lib/structure/io/binary/GraphBinary.js b/gremlin-js/gremlin-javascript/lib/structure/io/binary/GraphBinary.js
index 7f917b9b19..ab3cd58b0b 100644
--- a/gremlin-js/gremlin-javascript/lib/structure/io/binary/GraphBinary.js
+++ b/gremlin-js/gremlin-javascript/lib/structure/io/binary/GraphBinary.js
@@ -66,6 +66,7 @@ import MarkerSerializer from './internals/MarkerSerializer.js';
 import UnspecifiedNullSerializer from './internals/UnspecifiedNullSerializer.js';
 import EnumSerializer from './internals/EnumSerializer.js';
 import StubSerializer from './internals/StubSerializer.js';
+import CompositePDTSerializer from './internals/CompositePDTSerializer.js';
 import NumberSerializationStrategy from './internals/NumberSerializationStrategy.js';
 import AnySerializer from './internals/AnySerializer.js';
 import GraphBinaryReader from './internals/GraphBinaryReader.js';
@@ -81,35 +82,37 @@ function createIoc(anySerializerOptions) {
 
   ioc.serializers = {};
 
-ioc.intSerializer = new IntSerializer(ioc);
-ioc.longSerializer = new LongSerializer(ioc);
-ioc.stringSerializer = new StringSerializer(ioc, ioc.DataType.STRING);
-ioc.dateTimeSerializer = new DateTimeSerializer(ioc);
-ioc.doubleSerializer = new DoubleSerializer(ioc);
-ioc.floatSerializer = new FloatSerializer(ioc);
-ioc.listSerializer = new ArraySerializer(ioc, ioc.DataType.LIST);
-ioc.mapSerializer = new MapSerializer(ioc);
-ioc.setSerializer = new SetSerializer(ioc, ioc.DataType.SET);
-ioc.uuidSerializer = new UuidSerializer(ioc);
-ioc.edgeSerializer = new EdgeSerializer(ioc);
-ioc.pathSerializer = new PathSerializer(ioc);
-ioc.propertySerializer = new PropertySerializer(ioc);
-ioc.vertexSerializer = new VertexSerializer(ioc);
-ioc.vertexPropertySerializer = new VertexPropertySerializer(ioc);
-ioc.graphSerializer = new GraphSerializer(ioc);
-ioc.bigIntegerSerializer = new BigIntegerSerializer(ioc);
-ioc.byteSerializer = new ByteSerializer(ioc);
-ioc.binarySerializer = new BinarySerializer(ioc);
-ioc.shortSerializer = new ShortSerializer(ioc);
-ioc.booleanSerializer = new BooleanSerializer(ioc);
-ioc.markerSerializer = new MarkerSerializer(ioc);
-ioc.unspecifiedNullSerializer = new UnspecifiedNullSerializer(ioc);
-ioc.enumSerializer = new EnumSerializer(ioc);
-
-// Register stub serializers for unimplemented v4 types
-new StubSerializer(ioc, ioc.DataType.TREE, 'Tree');
-new StubSerializer(ioc, ioc.DataType.COMPOSITEPDT, 'CompositePDT');
-new StubSerializer(ioc, ioc.DataType.PRIMITIVEPDT, 'PrimitivePDT');
+  ioc.intSerializer = new IntSerializer(ioc);
+  ioc.longSerializer = new LongSerializer(ioc);
+  ioc.stringSerializer = new StringSerializer(ioc, ioc.DataType.STRING);
+  ioc.dateTimeSerializer = new DateTimeSerializer(ioc);
+  ioc.doubleSerializer = new DoubleSerializer(ioc);
+  ioc.floatSerializer = new FloatSerializer(ioc);
+  ioc.listSerializer = new ArraySerializer(ioc, ioc.DataType.LIST);
+  ioc.mapSerializer = new MapSerializer(ioc);
+  ioc.setSerializer = new SetSerializer(ioc, ioc.DataType.SET);
+  ioc.uuidSerializer = new UuidSerializer(ioc);
+  ioc.edgeSerializer = new EdgeSerializer(ioc);
+  ioc.pathSerializer = new PathSerializer(ioc);
+  ioc.propertySerializer = new PropertySerializer(ioc);
+  ioc.vertexSerializer = new VertexSerializer(ioc);
+  ioc.vertexPropertySerializer = new VertexPropertySerializer(ioc);
+  ioc.graphSerializer = new GraphSerializer(ioc);
+  ioc.bigIntegerSerializer = new BigIntegerSerializer(ioc);
+  ioc.byteSerializer = new ByteSerializer(ioc);
+  ioc.binarySerializer = new BinarySerializer(ioc);
+  ioc.shortSerializer = new ShortSerializer(ioc);
+  ioc.booleanSerializer = new BooleanSerializer(ioc);
+  ioc.markerSerializer = new MarkerSerializer(ioc);
+  ioc.unspecifiedNullSerializer = new UnspecifiedNullSerializer(ioc);
+  ioc.enumSerializer = new EnumSerializer(ioc);
+  ioc.compositePDTSerializer = new CompositePDTSerializer(ioc);
+
+  // Register stub serializers for unimplemented v4 types
+  new StubSerializer(ioc, ioc.DataType.TREE, 'Tree');
+  new StubSerializer(ioc, ioc.DataType.PRIMITIVEPDT, 'PrimitivePDT');
+
+  ioc.pdtRegistry = null;
 
   ioc.numberSerializationStrategy = new NumberSerializationStrategy(ioc);
   ioc.anySerializer = new AnySerializer(ioc, anySerializerOptions);
@@ -173,6 +176,7 @@ export const {
   markerSerializer,
   unspecifiedNullSerializer,
   enumSerializer,
+  compositePDTSerializer,
   numberSerializationStrategy,
   anySerializer,
   graphBinaryReader,
diff --git a/gremlin-js/gremlin-javascript/lib/structure/io/binary/internals/AnySerializer.js b/gremlin-js/gremlin-javascript/lib/structure/io/binary/internals/AnySerializer.js
index bcf78436f5..94841ad355 100644
--- a/gremlin-js/gremlin-javascript/lib/structure/io/binary/internals/AnySerializer.js
+++ b/gremlin-js/gremlin-javascript/lib/structure/io/binary/internals/AnySerializer.js
@@ -44,6 +44,7 @@ export default class AnySerializer {
       ioc.enumSerializer,
       ioc.stringSerializer,
       ioc.binarySerializer,
+      ioc.compositePDTSerializer,
       ioc.mapSerializer,
     ];
   }
diff --git a/gremlin-js/gremlin-javascript/lib/structure/io/binary/internals/CompositePDTSerializer.js b/gremlin-js/gremlin-javascript/lib/structure/io/binary/internals/CompositePDTSerializer.js
new file mode 100644
index 0000000000..af3e810c5b
--- /dev/null
+++ b/gremlin-js/gremlin-javascript/lib/structure/io/binary/internals/CompositePDTSerializer.js
@@ -0,0 +1,85 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+import { Buffer } from 'buffer';
+import { ProviderDefinedType } from '../../../graph.js';
+
+export default class CompositePDTSerializer {
+  constructor(ioc) {
+    this.ioc = ioc;
+    this.ioc.serializers[ioc.DataType.COMPOSITEPDT] = this;
+  }
+
+  canBeUsedFor(value) {
+    return value instanceof ProviderDefinedType;
+  }
+
+  serialize(item, fullyQualifiedFormat = true) {
+    if (item === undefined || item === null) {
+      if (fullyQualifiedFormat) {
+        return Buffer.from([this.ioc.DataType.COMPOSITEPDT, 0x01]);
+      }
+      const bufs = [];
+      bufs.push(this.ioc.stringSerializer.serialize('', false));
+      bufs.push(this.ioc.mapSerializer.serialize({}, false));
+      return Buffer.concat(bufs);
+    }
+
+    const bufs = [];
+    if (fullyQualifiedFormat) {
+      bufs.push(Buffer.from([this.ioc.DataType.COMPOSITEPDT, 0x00]));
+    }
+    bufs.push(this.ioc.stringSerializer.serialize(item.name, true));
+    bufs.push(this.ioc.mapSerializer.serialize(item.fields, true));
+    return Buffer.concat(bufs);
+  }
+
+  async deserializeValue(reader, valueFlag, typeCode) {
+    const name = await this.ioc.anySerializer.deserialize(reader);
+    if (!name) {
+      throw new Error('CompositePDTSerializer: name cannot be null or empty');
+    }
+    const fieldsRaw = await this.ioc.anySerializer.deserialize(reader);
+    const fields = fieldsRaw instanceof Map ? Object.fromEntries(fieldsRaw) : fieldsRaw || {};
+    const pdt = new ProviderDefinedType(name, fields);
+    const pdtRegistry = reader.pdtRegistry;
+    if (pdtRegistry) {
+      const hydrated = pdtRegistry.hydrate(pdt);
+      if (!(hydrated instanceof ProviderDefinedType)) {
+        return hydrated;
+      }
+    }
+    return pdt;
+  }
+
+  async deserialize(reader) {
+    const type_code = await reader.readUInt8();
+    if (type_code !== this.ioc.DataType.COMPOSITEPDT) {
+      throw new Error(`CompositePDTSerializer: unexpected {type_code}=0x${type_code.toString(16)}`);
+    }
+    const value_flag = await reader.readUInt8();
+    if (value_flag === 0x01) {
+      return null;
+    }
+    if (value_flag !== 0x00) {
+      throw new Error(`CompositePDTSerializer: unexpected {value_flag}=0x${value_flag.toString(16)}`);
+    }
+    return this.deserializeValue(reader, value_flag, type_code);
+  }
+}
diff --git a/gremlin-js/gremlin-javascript/lib/structure/io/binary/internals/GraphBinaryReader.js b/gremlin-js/gremlin-javascript/lib/structure/io/binary/internals/GraphBinaryReader.js
index a2e43175e2..a4bdddabf2 100644
--- a/gremlin-js/gremlin-javascript/lib/structure/io/binary/internals/GraphBinaryReader.js
+++ b/gremlin-js/gremlin-javascript/lib/structure/io/binary/internals/GraphBinaryReader.js
@@ -40,6 +40,7 @@ const StatusCode = {
 export default class GraphBinaryReader {
   constructor(ioc) {
     this.ioc = ioc;
+    this.pdtRegistry = null;
   }
 
   get mimeType() {
@@ -64,6 +65,7 @@ export default class GraphBinaryReader {
     }
 
     const reader = StreamReader.fromBuffer(buffer);
+    reader.pdtRegistry = this.pdtRegistry;
     return await this.#readFromReader(reader);
   }
 
@@ -81,6 +83,7 @@ export default class GraphBinaryReader {
    * @returns {AsyncGenerator<any>}
    */
   async *readResponseStream(reader) {
+    reader.pdtRegistry = this.pdtRegistry;
     // {version}
     const version = await reader.readUInt8();
     if (version !== 0x84) {
diff --git a/gremlin-js/gremlin-javascript/test/integration/client-tests.js b/gremlin-js/gremlin-javascript/test/integration/client-tests.js
index aca3548d40..cf439aaa56 100644
--- a/gremlin-js/gremlin-javascript/test/integration/client-tests.js
+++ b/gremlin-js/gremlin-javascript/test/integration/client-tests.js
@@ -18,7 +18,7 @@
  */
 
 import assert from 'assert';
-import { Vertex, Edge, VertexProperty } from '../../lib/structure/graph.js';
+import { Vertex, Edge, VertexProperty, ProviderDefinedType } from '../../lib/structure/graph.js';
 import { getClient, serverUrl } from '../helper.js';
 import { cardinality } from '../../lib/process/traversal.js';
 import Client from '../../lib/driver/client.js';
@@ -208,4 +208,69 @@ function assertVertexProperties(vertex) {
   assert.ok(end);
   assert.strictEqual(start.value, 1990);
   assert.strictEqual(end.value, 2000);
-}
\ No newline at end of file
+}
+
+describe('ProviderDefinedType - Client', function () {
+  let pdtClient;
+  before(function () {
+    pdtClient = getClient('gmodern');
+    return pdtClient.open();
+  });
+  after(function () {
+    return pdtClient.close();
+  });
+
+  it('should round-trip a simple Point PDT', function () {
+    return pdtClient.submit('g.inject(PDT("Point", ["x":1, "y":2]))')
+      .then(function (result) {
+        assert.strictEqual(result.length, 1);
+        const pdt = result.first();
+        assert.ok(pdt instanceof ProviderDefinedType);
+        assert.strictEqual(pdt.name, 'Point');
+        assert.strictEqual(pdt.fields.x, 1);
+        assert.strictEqual(pdt.fields.y, 2);
+      });
+  });
+
+  it('should round-trip a nested PDT (Person with Address)', function () {
+    return pdtClient.submit(
+      'g.inject(PDT("Person", ["name":"Alice", "age":30, ' +
+      '"address":PDT("Address", ["street":"123 Main St", "city":"Springfield", "zip":"12345"])]))')
+      .then(function (result) {
+        assert.strictEqual(result.length, 1);
+        const pdt = result.first();
+        assert.ok(pdt instanceof ProviderDefinedType);
+        assert.strictEqual(pdt.name, 'Person');
+        assert.strictEqual(pdt.fields.name, 'Alice');
+        assert.strictEqual(pdt.fields.age, 30);
+
+        const address = pdt.fields.address;
+        assert.ok(address instanceof ProviderDefinedType);
+        assert.strictEqual(address.name, 'Address');
+        assert.strictEqual(address.fields.street, '123 Main St');
+        assert.strictEqual(address.fields.city, 'Springfield');
+        assert.strictEqual(address.fields.zip, '12345');
+      });
+  });
+
+  it('should handle PDTs in a collection', function () {
+    return pdtClient.submit(
+      'g.inject([PDT("Point", ["x":1, "y":2]), PDT("Point", ["x":3, "y":4])])')
+      .then(function (result) {
+        assert.strictEqual(result.length, 1);
+        const list = result.first();
+        assert.ok(Array.isArray(list));
+        assert.strictEqual(list.length, 2);
+
+        assert.ok(list[0] instanceof ProviderDefinedType);
+        assert.strictEqual(list[0].name, 'Point');
+        assert.strictEqual(list[0].fields.x, 1);
+        assert.strictEqual(list[0].fields.y, 2);
+
+        assert.ok(list[1] instanceof ProviderDefinedType);
+        assert.strictEqual(list[1].name, 'Point');
+        assert.strictEqual(list[1].fields.x, 3);
+        assert.strictEqual(list[1].fields.y, 4);
+      });
+  });
+});
\ No newline at end of file
diff --git a/gremlin-js/gremlin-javascript/test/integration/traversal-test.js b/gremlin-js/gremlin-javascript/test/integration/traversal-test.js
index 2f7f544144..867080b040 100644
--- a/gremlin-js/gremlin-javascript/test/integration/traversal-test.js
+++ b/gremlin-js/gremlin-javascript/test/integration/traversal-test.js
@@ -23,7 +23,8 @@
 
 import assert from 'assert';
 import { AssertionError } from 'assert';
-import {Edge, Vertex, VertexProperty} from '../../lib/structure/graph.js';
+import {Edge, Vertex, VertexProperty, ProviderDefinedType} from '../../lib/structure/graph.js';
+import { ProviderDefinedTypeRegistry } from '../../lib/structure/ProviderDefinedTypeRegistry.js';
 import anon from '../../lib/process/anonymous-traversal.js';
 import { GraphTraversalSource, GraphTraversal, statics } from '../../lib/process/graph-traversal.js';
 import {
@@ -31,6 +32,7 @@ import {
   OptionsStrategy, ReservedKeysVerificationStrategy, EdgeLabelVerificationStrategy, MatchAlgorithmStrategy
 } from '../../lib/process/traversal-strategy.js';
 import GremlinLang from '../../lib/process/gremlin-lang.js';
+import DriverRemoteConnection from '../../lib/driver/driver-remote-connection.js';
 import { getConnection, getDriverRemoteConnection } from '../helper.js';
 const __ = statics;
 
@@ -328,3 +330,77 @@ describe('Traversal', function () {
   });
 });
 
+let serverUrl;
+if (process.env.DOCKER_ENVIRONMENT === 'true') {
+  serverUrl = 'http://gremlin-server-test-js:45940/gremlin';
+} else {
+  serverUrl = 'http://localhost:45940/gremlin';
+}
+
+describe('ProviderDefinedType - Traversal API', function () {
+  describe('raw PDT round-trip via Traversal API', function () {
+    let pdtConnection;
+
+    before(function () {
+      pdtConnection = getConnection('gmodern');
+      return pdtConnection.open();
+    });
+    after(function () {
+      return pdtConnection.close();
+    });
+
+    it('should round-trip a PDT via g.inject()', async function () {
+      const g = anon.traversal().with_(pdtConnection);
+      const pdt = new ProviderDefinedType('TestPoint', { x: 1, y: 2 });
+
+      const results = await g.inject(pdt).toList();
+
+      assert.strictEqual(results.length, 1);
+      const result = results[0];
+      assert.ok(result instanceof ProviderDefinedType);
+      assert.strictEqual(result.name, 'TestPoint');
+      assert.strictEqual(result.fields.x, 1);
+      assert.strictEqual(result.fields.y, 2);
+    });
+  });
+
+  describe('registry-based round-trip via typed object', function () {
+    let pdtConnection;
+
+    class TestPoint {
+      constructor(x, y) {
+        this.x = x;
+        this.y = y;
+      }
+    }
+
+    before(function () {
+      const registry = new ProviderDefinedTypeRegistry();
+      registry.register('TestPoint', {
+        serialize: (obj) => ({ x: obj.x, y: obj.y }),
+        deserialize: (fields) => new TestPoint(fields.x, fields.y),
+      }, TestPoint);
+      pdtConnection = new DriverRemoteConnection(serverUrl, {
+        traversalSource: 'gmodern',
+        pdtRegistry: registry,
+      });
+      return pdtConnection.open();
+    });
+    after(function () {
+      return pdtConnection.close();
+    });
+
+    it('should auto-dehydrate on send and auto-hydrate on receive via registry', async function () {
+      const g = anon.traversal().with_(pdtConnection);
+      const point = new TestPoint(5, 10);
+
+      const results = await g.inject(point).toList();
+
+      assert.strictEqual(results.length, 1);
+      const result = results[0];
+      assert.ok(result instanceof TestPoint);
+      assert.strictEqual(result.x, 5);
+      assert.strictEqual(result.y, 10);
+    });
+  });
+});
diff --git a/gremlin-js/gremlin-javascript/test/unit/graphbinary/CompositePDTSerializer-test.js b/gremlin-js/gremlin-javascript/test/unit/graphbinary/CompositePDTSerializer-test.js
new file mode 100644
index 0000000000..43f81430e8
--- /dev/null
+++ b/gremlin-js/gremlin-javascript/test/unit/graphbinary/CompositePDTSerializer-test.js
@@ -0,0 +1,150 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+import { assert } from 'chai';
+import { ProviderDefinedType } from '../../../lib/structure/graph.js';
+import { ProviderDefinedTypeRegistry } from '../../../lib/structure/ProviderDefinedTypeRegistry.js';
+import ioc, { DataType } from '../../../lib/structure/io/binary/GraphBinary.js';
+import StreamReader from '../../../lib/structure/io/binary/internals/StreamReader.js';
+
+const { anySerializer, compositePDTSerializer } = ioc;
+
+async function roundTrip(value) {
+  const bytes = anySerializer.serialize(value);
+  return anySerializer.deserialize(StreamReader.fromBuffer(bytes));
+}
+
+describe('CompositePDTSerializer', () => {
+  describe('round-trip: simple PDT', () => {
+    it('serializes and deserializes a simple ProviderDefinedType', async () => {
+      const pdt = new ProviderDefinedType('myType', { key1: 'value1', key2: 42 });
+      const result = await roundTrip(pdt);
+      assert.instanceOf(result, ProviderDefinedType);
+      assert.strictEqual(result.name, 'myType');
+      assert.strictEqual(result.fields.key1, 'value1');
+      assert.strictEqual(result.fields.key2, 42);
+    });
+
+    it('uses COMPOSITEPDT type code', () => {
+      const pdt = new ProviderDefinedType('test', { a: 1 });
+      const bytes = anySerializer.serialize(pdt);
+      assert.strictEqual(bytes[0], DataType.COMPOSITEPDT);
+    });
+  });
+
+  describe('round-trip: nested PDT', () => {
+    it('serializes and deserializes a PDT with nested PDT in fields', async () => {
+      const inner = new ProviderDefinedType('inner', { x: 'hello' });
+      const outer = new ProviderDefinedType('outer', { nested: inner, num: 99 });
+      const result = await roundTrip(outer);
+      assert.instanceOf(result, ProviderDefinedType);
+      assert.strictEqual(result.name, 'outer');
+      assert.strictEqual(result.fields.num, 99);
+      assert.instanceOf(result.fields.nested, ProviderDefinedType);
+      assert.strictEqual(result.fields.nested.name, 'inner');
+      assert.strictEqual(result.fields.nested.fields.x, 'hello');
+    });
+  });
+
+  describe('round-trip: null/undefined field value', () => {
+    it('handles null field values', async () => {
+      const pdt = new ProviderDefinedType('withNull', { present: 'yes', absent: null });
+      const result = await roundTrip(pdt);
+      assert.instanceOf(result, ProviderDefinedType);
+      assert.strictEqual(result.name, 'withNull');
+      assert.strictEqual(result.fields.present, 'yes');
+      assert.strictEqual(result.fields.absent, null);
+    });
+  });
+
+  describe('empty name rejected', () => {
+    it('constructor rejects empty string name', () => {
+      assert.throws(() => new ProviderDefinedType('', { a: 1 }), /name cannot be null or empty/);
+    });
+
+    it('constructor rejects null name', () => {
+      assert.throws(() => new ProviderDefinedType(null, { a: 1 }), /name cannot be null or empty/);
+    });
+
+    it('constructor rejects undefined name', () => {
+      assert.throws(() => new ProviderDefinedType(undefined, { a: 1 }), /name cannot be null or empty/);
+    });
+
+    it('deserializer rejects null name from wire', async () => {
+      // Manually craft bytes: type_code=0xf0, value_flag=0x00, then null string, then empty map
+      const nullString = Buffer.from([DataType.STRING, 0x01]); // null string
+      const emptyMap = Buffer.from([DataType.MAP, 0x00, 0x00, 0x00, 0x00, 0x00]); // map with 0 entries
+      const bytes = Buffer.concat([
+        Buffer.from([DataType.COMPOSITEPDT, 0x00]),
+        nullString,
+        emptyMap,
+      ]);
+      try {
+        await anySerializer.deserialize(StreamReader.fromBuffer(bytes));
+        assert.fail('should have thrown');
+      } catch (e) {
+        assert.match(e.message, /name cannot be null or empty/);
+      }
+    });
+  });
+
+  describe('canBeUsedFor', () => {
+    it('returns true for ProviderDefinedType instances', () => {
+      assert.isTrue(compositePDTSerializer.canBeUsedFor(new ProviderDefinedType('t', {})));
+    });
+
+    it('returns false for plain objects', () => {
+      assert.isFalse(compositePDTSerializer.canBeUsedFor({ name: 'test' }));
+    });
+
+    it('returns false for strings', () => {
+      assert.isFalse(compositePDTSerializer.canBeUsedFor('test'));
+    });
+  });
+
+  describe('auto-hydration via pdtRegistry', () => {
+    it('auto-hydrates when pdtRegistry is set on the reader', async () => {
+      const registry = new ProviderDefinedTypeRegistry();
+      registry.register('myType', {
+        serialize: (obj) => obj,
+        deserialize: (fields) => ({ hydrated: true, ...fields }),
+      });
+
+      const pdt = new ProviderDefinedType('myType', { key1: 'value1', key2: 42 });
+      const bytes = anySerializer.serialize(pdt);
+      const reader = StreamReader.fromBuffer(bytes);
+      reader.pdtRegistry = registry;
+      const result = await anySerializer.deserialize(reader);
+
+      assert.notInstanceOf(result, ProviderDefinedType);
+      assert.strictEqual(result.hydrated, true);
+      assert.strictEqual(result.key1, 'value1');
+      assert.strictEqual(result.key2, 42);
+    });
+
+    it('returns raw PDT when no pdtRegistry is set', async () => {
+      const pdt = new ProviderDefinedType('myType', { key1: 'value1' });
+      const bytes = anySerializer.serialize(pdt);
+      const result = await anySerializer.deserialize(StreamReader.fromBuffer(bytes));
+
+      assert.instanceOf(result, ProviderDefinedType);
+      assert.strictEqual(result.name, 'myType');
+    });
+  });
+});
diff --git a/gremlin-js/gremlin-javascript/test/unit/graphbinary/precise-mode-test.js b/gremlin-js/gremlin-javascript/test/unit/graphbinary/precise-mode-test.js
index c2b9246713..259642aa89 100644
--- a/gremlin-js/gremlin-javascript/test/unit/graphbinary/precise-mode-test.js
+++ b/gremlin-js/gremlin-javascript/test/unit/graphbinary/precise-mode-test.js
@@ -515,7 +515,7 @@ describe('Precise Mode Tests', () => {
 
     it('default uses the default reader', () => {
       const conn = new Connection('http://localhost:8182', {});
-      assert.strictEqual(conn._reader, graphBinaryReader);
+      assert.ok(conn._reader instanceof graphBinaryReader.constructor);
     });
   });
 });
diff --git a/gremlin-js/gremlin-javascript/test/unit/gremlin-lang-test.js b/gremlin-js/gremlin-javascript/test/unit/gremlin-lang-test.js
index a8c4e9add0..73ccf69e36 100644
--- a/gremlin-js/gremlin-javascript/test/unit/gremlin-lang-test.js
+++ b/gremlin-js/gremlin-javascript/test/unit/gremlin-lang-test.js
@@ -25,7 +25,7 @@ import { P, TextP, t as T, order as Order, scope as Scope, column as Column,
          withOptions as WithOptions, direction } from '../../lib/process/traversal.js';
 import { ReadOnlyStrategy, SubgraphStrategy, OptionsStrategy,
          PartitionStrategy, SeedStrategy } from '../../lib/process/traversal-strategy.js';
-import { Graph, Vertex } from '../../lib/structure/graph.js';
+import { Graph, Vertex, ProviderDefinedType } from '../../lib/structure/graph.js';
 import { TraversalStrategies } from '../../lib/process/traversal-strategy.js';
 import { Long, toFloat, toDouble, toShort, toByte, toInt, toLong } from '../../lib/utils.js';
 import GremlinLang from '../../lib/process/gremlin-lang.js';
@@ -626,4 +626,39 @@ describe('GremlinLang', function () {
       assert.ok(result.includes("'name':'marko'"));
     });
   });
-});
\ No newline at end of file
+
+  describe('PDT gremlin-lang tests', function () {
+    it('should handle basic PDT', function () {
+      const pdt = new ProviderDefinedType('Point', { x: 1, y: 2 });
+      assert.strictEqual(
+        g.inject(pdt).getGremlinLang().getGremlin(),
+        "g.inject(PDT(\"Point\",['x':1,'y':2]))"
+      );
+    });
+
+    it('should handle PDT with special chars in name (quotes)', function () {
+      const pdt = new ProviderDefinedType('my"type', { a: 1 });
+      assert.strictEqual(
+        g.inject(pdt).getGremlinLang().getGremlin(),
+        "g.inject(PDT(\"my\\\"type\",['a':1]))"
+      );
+    });
+
+    it('should handle nested PDT', function () {
+      const inner = new ProviderDefinedType('Inner', { v: 42 });
+      const outer = new ProviderDefinedType('Outer', { child: inner });
+      assert.strictEqual(
+        g.inject(outer).getGremlinLang().getGremlin(),
+        "g.inject(PDT(\"Outer\",['child':PDT(\"Inner\",['v':42])]))"
+      );
+    });
+
+    it('should handle PDT with empty fields', function () {
+      const pdt = new ProviderDefinedType('Empty', {});
+      assert.strictEqual(
+        g.inject(pdt).getGremlinLang().getGremlin(),
+        "g.inject(PDT(\"Empty\",[:]))"
+      );
+    });
+  });
+});
diff --git a/gremlin-js/gremlin-javascript/test/unit/pdt-registry-test.js b/gremlin-js/gremlin-javascript/test/unit/pdt-registry-test.js
new file mode 100644
index 0000000000..ded764b7c6
--- /dev/null
+++ b/gremlin-js/gremlin-javascript/test/unit/pdt-registry-test.js
@@ -0,0 +1,174 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+import { assert } from 'chai';
+import { ProviderDefinedType } from '../../lib/structure/graph.js';
+import { ProviderDefinedTypeRegistry } from '../../lib/structure/ProviderDefinedTypeRegistry.js';
+import Client from '../../lib/driver/client.js';
+import Connection from '../../lib/driver/connection.js';
+
+describe('ProviderDefinedTypeRegistry', () => {
+  describe('#hydrate()', () => {
+    it('should return a typed object when an adapter is registered', () => {
+      const registry = new ProviderDefinedTypeRegistry();
+      registry.register('GeoPoint', {
+        serialize: (obj) => ({ lat: obj.lat, lon: obj.lon }),
+        deserialize: (fields) => ({ type: 'GeoPoint', lat: fields.lat, lon: fields.lon }),
+      });
+
+      const pdt = new ProviderDefinedType('GeoPoint', { lat: 37.7749, lon: -122.4194 });
+      const result = registry.hydrate(pdt);
+
+      assert.deepStrictEqual(result, { type: 'GeoPoint', lat: 37.7749, lon: -122.4194 });
+    });
+
+    it('should return the raw PDT when no adapter is registered', () => {
+      const registry = new ProviderDefinedTypeRegistry();
+      const pdt = new ProviderDefinedType('Unknown', { foo: 'bar' });
+      const result = registry.hydrate(pdt);
+
+      assert.strictEqual(result, pdt);
+      assert.instanceOf(result, ProviderDefinedType);
+    });
+
+    it('should fall back gracefully when adapter throws', () => {
+      const registry = new ProviderDefinedTypeRegistry();
+      registry.register('Broken', {
+        serialize: () => ({}),
+        deserialize: () => { throw new Error('adapter error'); },
+      });
+
+      const pdt = new ProviderDefinedType('Broken', { x: 1 });
+      const warnings = [];
+      const origWarn = console.warn;
+      console.warn = (msg) => warnings.push(msg);
+      try {
+        const result = registry.hydrate(pdt);
+        assert.strictEqual(result, pdt);
+        assert.lengthOf(warnings, 1);
+        assert.include(warnings[0], 'adapter error');
+        assert.include(warnings[0], 'Broken');
+      } finally {
+        console.warn = origWarn;
+      }
+    });
+
+    it('should recursively hydrate nested PDTs', () => {
+      const registry = new ProviderDefinedTypeRegistry();
+      registry.register('Address', {
+        serialize: (obj) => obj,
+        deserialize: (fields) => ({ type: 'Address', city: fields.city, zip: fields.zip }),
+      });
+      registry.register('Person', {
+        serialize: (obj) => obj,
+        deserialize: (fields) => ({ type: 'Person', name: fields.name, address: fields.address }),
+      });
+
+      const addressPdt = new ProviderDefinedType('Address', { city: 'Portland', zip: '97201' });
+      const personPdt = new ProviderDefinedType('Person', { name: 'Alice', address: addressPdt });
+
+      const result = registry.hydrate(personPdt);
+
+      assert.deepStrictEqual(result, {
+        type: 'Person',
+        name: 'Alice',
+        address: { type: 'Address', city: 'Portland', zip: '97201' },
+      });
+    });
+
+    it('should return non-PDT values unchanged', () => {
+      const registry = new ProviderDefinedTypeRegistry();
+      assert.strictEqual(registry.hydrate('hello'), 'hello');
+      assert.strictEqual(registry.hydrate(42), 42);
+      assert.strictEqual(registry.hydrate(null), null);
+    });
+
+    it('should hydrate nested registered PDT inside unregistered outer', () => {
+      const registry = new ProviderDefinedTypeRegistry();
+      registry.register('Inner', {
+        serialize: (obj) => ({ val: obj.val }),
+        deserialize: (fields) => ({ type: 'Inner', val: fields.val }),
+      });
+
+      const innerPdt = new ProviderDefinedType('Inner', { val: 42 });
+      const outerPdt = new ProviderDefinedType('Outer', { nested: innerPdt, plain: 'hello' });
+      const result = registry.hydrate(outerPdt);
+
+      assert.instanceOf(result, ProviderDefinedType);
+      assert.strictEqual(result.name, 'Outer');
+      assert.deepStrictEqual(result.fields.nested, { type: 'Inner', val: 42 });
+      assert.strictEqual(result.fields.plain, 'hello');
+    });
+  });
+
+  describe('#hasAdapter()', () => {
+    it('should return true for registered types', () => {
+      const registry = new ProviderDefinedTypeRegistry();
+      registry.register('Foo', { serialize: () => ({}), deserialize: (p) => p });
+      assert.isTrue(registry.hasAdapter('Foo'));
+      assert.isFalse(registry.hasAdapter('Bar'));
+    });
+  });
+
+  describe('#getSerializer()', () => {
+    it('should return the serialize function for registered types', () => {
+      const registry = new ProviderDefinedTypeRegistry();
+      const serFn = (obj) => ({ val: obj.val });
+      registry.register('Custom', { serialize: serFn, deserialize: (p) => p });
+      assert.strictEqual(registry.getSerializer('Custom'), serFn);
+    });
+
+    it('should return null for unregistered types', () => {
+      const registry = new ProviderDefinedTypeRegistry();
+      assert.isNull(registry.getSerializer('Missing'));
+    });
+  });
+});
+
+describe('pdtRegistry wiring through Client/Connection', () => {
+  it('should set pdtRegistry on the reader when passed via Connection options', () => {
+    const registry = new ProviderDefinedTypeRegistry();
+    registry.register('GeoPoint', {
+      serialize: (obj) => obj,
+      deserialize: (fields) => ({ type: 'GeoPoint', ...fields }),
+    });
+
+    const conn = new Connection('http://localhost:8182', { pdtRegistry: registry });
+    assert.strictEqual(conn._reader.pdtRegistry, registry);
+  });
+
+  it('should set pdtRegistry on the reader when passed via Client options', () => {
+    const registry = new ProviderDefinedTypeRegistry();
+    registry.register('GeoPoint', {
+      serialize: (obj) => obj,
+      deserialize: (fields) => ({ type: 'GeoPoint', ...fields }),
+    });
+
+    const client = new Client('http://localhost:8182', { pdtRegistry: registry });
+    assert.strictEqual(client._connection._reader.pdtRegistry, registry);
+  });
+
+  it('should not leak pdtRegistry between connections', () => {
+    const registry = new ProviderDefinedTypeRegistry();
+    const conn1 = new Connection('http://localhost:8182', { pdtRegistry: registry });
+    const conn2 = new Connection('http://localhost:8182');
+    assert.isNull(conn2._reader.pdtRegistry);
+    assert.strictEqual(conn1._reader.pdtRegistry, registry);
+  });
+});
diff --git a/gremlin-language/src/main/antlr4/Gremlin.g4 b/gremlin-language/src/main/antlr4/Gremlin.g4
index 6798ebde55..fbe8d41404 100644
--- a/gremlin-language/src/main/antlr4/Gremlin.g4
+++ b/gremlin-language/src/main/antlr4/Gremlin.g4
@@ -1636,6 +1636,7 @@ genericLiteral
     | characterLiteral
     | durationLiteral
     | binaryLiteral
+    | pdtLiteral
     | genericMapLiteral
     ;
 
@@ -1728,6 +1729,10 @@ binaryLiteral
     : K_BINARYC LPAREN stringLiteral RPAREN
     ;
 
+pdtLiteral
+    : K_PDT LPAREN stringLiteral COMMA genericMapLiteral RPAREN
+    ;
+
 
 nakedKey
     : Identifier
@@ -1951,6 +1956,7 @@ keyword
     | K_PAGERANKU
     | K_PATH
     | K_PATHU
+    | K_PDT
     | K_PEERPRESSURE
     | K_PEERPRESSUREU
     | K_PICK
@@ -2263,6 +2269,7 @@ K_PAGERANKU: 'PageRank';
 K_PAGERANK: 'pageRank';
 K_PATH: 'path';
 K_PATHU: 'PATH';
+K_PDT: 'PDT';
 K_PEERPRESSUREU: 'PeerPressure';
 K_PEERPRESSURE: 'peerPressure';
 K_PICK: 'Pick';
diff --git a/gremlin-python/src/main/python/gremlin_python/driver/client.py b/gremlin-python/src/main/python/gremlin_python/driver/client.py
index 9d5b419120..fa4dbc32c2 100644
--- a/gremlin-python/src/main/python/gremlin_python/driver/client.py
+++ b/gremlin-python/src/main/python/gremlin_python/driver/client.py
@@ -42,7 +42,7 @@ class Client:
                  request_serializer=serializer.GraphBinarySerializersV4(),
                  response_serializer=None, interceptors=None, auth=None,
                  headers=None, enable_user_agent_on_connect=True,
-                 bulk_results=False, **transport_kwargs):
+                 bulk_results=False, pdt_registry=None, **transport_kwargs):
         log.info("Creating Client with url '%s'", url)
 
         self._closed = False
@@ -56,6 +56,10 @@ class Client:
         self._traversal_source = traversal_source
         if response_serializer is None:
             response_serializer = serializer.GraphBinarySerializersV4()
+        if pdt_registry is not None:
+            if request_serializer is not None:
+                request_serializer.configure_pdt_registry(pdt_registry)
+            response_serializer.configure_pdt_registry(pdt_registry)
 
         self._auth = auth
         self._response_serializer = response_serializer
diff --git a/gremlin-python/src/main/python/gremlin_python/driver/driver_remote_connection.py b/gremlin-python/src/main/python/gremlin_python/driver/driver_remote_connection.py
index ea03617de8..279c2f4845 100644
--- a/gremlin-python/src/main/python/gremlin_python/driver/driver_remote_connection.py
+++ b/gremlin-python/src/main/python/gremlin_python/driver/driver_remote_connection.py
@@ -36,7 +36,7 @@ class DriverRemoteConnection(RemoteConnection):
                  request_serializer=serializer.GraphBinarySerializersV4(),
                  response_serializer=None, interceptors=None, auth=None,
                  headers=None, enable_user_agent_on_connect=True,
-                 bulk_results=False, **transport_kwargs):
+                 bulk_results=False, pdt_registry=None, **transport_kwargs):
         log.info("Creating DriverRemoteConnection with url '%s'", str(url))
         self.__url = url
         self.__traversal_source = traversal_source
@@ -47,6 +47,7 @@ class DriverRemoteConnection(RemoteConnection):
         self.__enable_user_agent_on_connect = enable_user_agent_on_connect
         self.__bulk_results = bulk_results
         self.__transport_kwargs = transport_kwargs
+        self.pdt_registry = pdt_registry
 
         if response_serializer is None:
             response_serializer = serializer.GraphBinarySerializersV4()
@@ -59,6 +60,7 @@ class DriverRemoteConnection(RemoteConnection):
                                      headers=headers,
                                      enable_user_agent_on_connect=enable_user_agent_on_connect,
                                      bulk_results=bulk_results,
+                                     pdt_registry=pdt_registry,
                                      **transport_kwargs)
         self._url = self._client._url
         self._traversal_source = self._client._traversal_source
diff --git a/gremlin-python/src/main/python/gremlin_python/driver/serializer.py b/gremlin-python/src/main/python/gremlin_python/driver/serializer.py
index 5c51f2507b..c333a4cc54 100644
--- a/gremlin-python/src/main/python/gremlin_python/driver/serializer.py
+++ b/gremlin-python/src/main/python/gremlin_python/driver/serializer.py
@@ -34,17 +34,24 @@ class GraphBinarySerializersV4(object):
 
     int_pack = graphbinaryV4.int32_pack
 
-    def __init__(self, reader=None, writer=None, version=None):
+    def __init__(self, reader=None, writer=None, version=None, pdt_registry=None):
         if not version:
             version = self.DEFAULT_VERSION
         self._version = version
         if not reader:
-            reader = self.DEFAULT_READER_CLASS()
+            reader = self.DEFAULT_READER_CLASS(pdt_registry=pdt_registry)
         self._graphbinary_reader = reader
         if not writer:
             writer = self.DEFAULT_WRITER_CLASS()
         self._graphbinary_writer = writer
 
+    def configure_pdt_registry(self, pdt_registry):
+        if self._graphbinary_reader.pdt_registry is None:
+            self._graphbinary_reader.pdt_registry = pdt_registry
+        else:
+            self._graphbinary_reader.pdt_registry._adapters_by_name.update(pdt_registry._adapters_by_name)
+            self._graphbinary_reader.pdt_registry._adapters_by_class.update(pdt_registry._adapters_by_class)
+
     @property
     def version(self):
         """Read only property"""
diff --git a/gremlin-python/src/main/python/gremlin_python/process/graph_traversal.py b/gremlin-python/src/main/python/gremlin_python/process/graph_traversal.py
index 4e83e857ba..34c54febb0 100644
--- a/gremlin-python/src/main/python/gremlin_python/process/graph_traversal.py
+++ b/gremlin-python/src/main/python/gremlin_python/process/graph_traversal.py
@@ -46,6 +46,8 @@ class GraphTraversalSource(object):
         self.graph_traversal = GraphTraversal
         if remote_connection:
             self.traversal_strategies.add_strategies([RemoteStrategy(remote_connection)])
+            if hasattr(remote_connection, 'pdt_registry') and remote_connection.pdt_registry is not None:
+                self.gremlin_lang.pdt_registry = remote_connection.pdt_registry
         self.remote_connection = remote_connection
 
     def __repr__(self):
diff --git a/gremlin-python/src/main/python/gremlin_python/process/traversal.py b/gremlin-python/src/main/python/gremlin_python/process/traversal.py
index de2ce44f2e..cbc035ee2c 100644
--- a/gremlin-python/src/main/python/gremlin_python/process/traversal.py
+++ b/gremlin-python/src/main/python/gremlin_python/process/traversal.py
@@ -24,7 +24,7 @@ import uuid
 import warnings
 
 from aenum import Enum
-from gremlin_python.structure.graph import Vertex, Edge, Path, Property
+from gremlin_python.structure.graph import Vertex, Edge, Path, Property, ProviderDefinedType
 
 from .. import statics
 from ..statics import long, SingleByte, SingleChar, short, bigint, BigDecimal
@@ -814,11 +814,13 @@ class GremlinLang(object):
         self.gremlin = []
         self.parameters = {}
         self.options_strategies = []
+        self.pdt_registry = None
 
         if gremlin_lang is not None:
             self.gremlin = list(gremlin_lang.gremlin)
             self.parameters = dict(gremlin_lang.parameters)
             self.options_strategies = list(gremlin_lang.options_strategies)
+            self.pdt_registry = gremlin_lang.pdt_registry
 
     def _add_to_gremlin(self, string_name, *args):
 
@@ -904,6 +906,9 @@ class GremlinLang(object):
             else:
                 return tmp
 
+        if isinstance(arg, ProviderDefinedType):
+            return f'PDT({self._arg_as_string(arg.name)},{self._process_dict(arg.fields)})'
+
         if isinstance(arg, Vertex):
             return f'{self._arg_as_string(arg.id)}'
 
@@ -947,6 +952,27 @@ class GremlinLang(object):
         if isinstance(arg, type):
             return arg.__name__
 
+        # Registry-based dehydration — a registered adapter intentionally takes
+        # precedence over the @provider_defined decorator fallback below, allowing
+        # explicit adapters to override decorator-derived behavior.
+        if self.pdt_registry is not None:
+            adapter = self.pdt_registry.get_adapter_by_class(type(arg))
+            if adapter is not None and adapter['serialize'] is not None:
+                fields = adapter['serialize'](arg)
+                return self._arg_as_string(ProviderDefinedType(adapter['type_name'], fields))
+
+        # Auto-dehydrate @provider_defined decorated objects
+        if hasattr(arg, '_pdt_name'):
+            included = getattr(arg, '_pdt_included_fields', None)
+            excluded = getattr(arg, '_pdt_excluded_fields', None)
+            fields = [f for f in vars(arg) if not f.startswith('_')]
+            if included:
+                fields = [f for f in fields if f in included]
+            elif excluded:
+                fields = [f for f in fields if f not in excluded]
+            pdt = ProviderDefinedType(arg._pdt_name, {f: getattr(arg, f) for f in fields})
+            return self._arg_as_string(pdt)
+
         raise TypeError(f'GremlinLang contains at least one type [{type(arg).__name__}] that cannot be represented as text.')
 
     # Do special processing needed to format predicates that come in
diff --git a/gremlin-python/src/main/python/gremlin_python/structure/graph.py b/gremlin-python/src/main/python/gremlin_python/structure/graph.py
index 61f92c3065..619eb64899 100644
--- a/gremlin-python/src/main/python/gremlin_python/structure/graph.py
+++ b/gremlin-python/src/main/python/gremlin_python/structure/graph.py
@@ -141,3 +141,127 @@ class Path(object):
 
     def __len__(self):
         return len(self.objects)
+
+
+class ProviderDefinedType(object):
+    def __init__(self, name, fields):
+        if not name:
+            raise ValueError("name cannot be null or empty")
+        self._name = name
+        self._fields = dict(fields) if fields else {}
+        if any(not isinstance(k, str) for k in self._fields):
+            raise TypeError("ProviderDefinedType field keys must be strings")
+
+    @property
+    def name(self):
+        return self._name
+
+    @property
+    def fields(self):
+        return self._fields
+
+    def __eq__(self, other):
+        return isinstance(other, ProviderDefinedType) and self._name == other._name and self._fields == other._fields
+
+    def __hash__(self):
+        try:
+            return hash((self._name, frozenset(self._fields.items())))
+        except TypeError:
+            return hash(self._name)
+
+    def __repr__(self):
+        return f"pdt[{self._name}]{self._fields}"
+
+
+class ProviderDefinedTypeRegistry(object):
+    def __init__(self):
+        self._adapters_by_name = {}
+        self._adapters_by_class = {}
+
+    def register(self, type_name, deserialize_fn, serialize_fn=None, target_class=None):
+        self._adapters_by_name[type_name] = {
+            'deserialize': deserialize_fn,
+            'serialize': serialize_fn,
+            'target_class': target_class
+        }
+        if target_class is not None:
+            self._adapters_by_class[target_class] = {
+                'type_name': type_name,
+                'serialize': serialize_fn,
+            }
+
+    @classmethod
+    def create(cls):
+        """Create a registry populated by entry_points discovery.
+
+        Providers register adapters via pyproject.toml:
+            [project.entry-points."tinkerpop.pdt"]
+            my_types = "my_package:register_pdt_types"
+
+        Each entry point should be a callable that accepts a registry and registers adapters.
+        """
+        import sys
+        registry = cls()
+        if sys.version_info >= (3, 10):
+            from importlib.metadata import entry_points
+            eps = entry_points(group='tinkerpop.pdt')
+        else:
+            from importlib.metadata import entry_points
+            all_eps = entry_points()
+            eps = all_eps.get('tinkerpop.pdt', [])
+
+        for ep in eps:
+            try:
+                factory = ep.load()
+                factory(registry)
+            except Exception as e:
+                import logging
+                logging.getLogger(__name__).warning(
+                    f"Failed to load PDT adapter from entry point '{ep.name}': {e}")
+        return registry
+
+    def hydrate(self, pdt):
+        """Attempt to hydrate a ProviderDefinedType. Returns typed object or raw PDT."""
+        if not isinstance(pdt, ProviderDefinedType):
+            return pdt
+
+        # Always recurse into fields to hydrate nested registered PDTs.
+        changed = False
+        hydrated_fields = {}
+        for k, v in pdt.fields.items():
+            if isinstance(v, ProviderDefinedType):
+                h = self.hydrate(v)
+                if h is not v:
+                    changed = True
+                hydrated_fields[k] = h
+            else:
+                hydrated_fields[k] = v
+
+        adapter = self._adapters_by_name.get(pdt.name)
+        if adapter is None:
+            return ProviderDefinedType(pdt.name, hydrated_fields) if changed else pdt
+        try:
+            return adapter['deserialize'](hydrated_fields)
+        except Exception as e:
+            import logging
+            logging.getLogger(__name__).warning(f"PDT hydration failed for '{pdt.name}': {e}")
+            return pdt
+
+    def get_adapter_by_class(self, cls):
+        """Return (type_name, serialize_fn) tuple for the given class, or None."""
+        return self._adapters_by_class.get(cls)
+
+
+# Module-level registry of @provider_defined decorated classes keyed by PDT name.
+_pdt_decorated_types = {}
+
+
+def provider_defined(name=None, included_fields=None, excluded_fields=None):
+    """Decorator that marks a class as a Provider Defined Type."""
+    def decorator(cls):
+        cls._pdt_name = name or cls.__name__
+        cls._pdt_included_fields = included_fields
+        cls._pdt_excluded_fields = excluded_fields
+        _pdt_decorated_types[cls._pdt_name] = cls
+        return cls
+    return decorator
diff --git a/gremlin-python/src/main/python/gremlin_python/structure/io/graphbinaryV4.py b/gremlin-python/src/main/python/gremlin_python/structure/io/graphbinaryV4.py
index 4cdc3a14ee..c6098619ce 100644
--- a/gremlin-python/src/main/python/gremlin_python/structure/io/graphbinaryV4.py
+++ b/gremlin-python/src/main/python/gremlin_python/structure/io/graphbinaryV4.py
@@ -30,7 +30,8 @@ from aenum import Enum
 from gremlin_python.process.traversal import Direction, T, Merge, GType
 from gremlin_python.statics import FloatType, BigDecimal, ShortType, IntType, LongType, BigIntType, \
     DictType, SetType, SingleByte, SingleChar
-from gremlin_python.structure.graph import Graph, Edge, Property, Vertex, VertexProperty, Path
+from gremlin_python.structure.graph import Graph, Edge, Property, Vertex, VertexProperty, Path, ProviderDefinedType, \
+    _pdt_decorated_types
 from gremlin_python.structure.io.util import HashableDict, SymbolUtil, Marker
 
 log = logging.getLogger(__name__)
@@ -73,8 +74,8 @@ class DataType(Enum):
     gtype = 0x30
     char = 0x80
     duration = 0x81
+    composite_pdt = 0xf0
     marker = 0xfd
-    custom = 0x00                 # todo
 
 
 NULL_BYTES = [DataType.null.value, 0x01]
@@ -144,10 +145,11 @@ class GraphBinaryWriter(object):
 
 
 class GraphBinaryReader(object):
-    def __init__(self, deserializer_map=None):
+    def __init__(self, deserializer_map=None, pdt_registry=None):
         self.deserializers = _deserializers.copy()
         if deserializer_map:
             self.deserializers.update(deserializer_map)
+        self.pdt_registry = pdt_registry
 
     def read_object(self, b):
         if b is None:
@@ -163,9 +165,33 @@ class GraphBinaryReader(object):
                 if nullable:
                     buff.read(1)
                 return None
-            return self.deserializers[DataType(bt)].objectify(buff, self, nullable)
+            result = self.deserializers[DataType(bt)].objectify(buff, self, nullable)
         else:
-            return self.deserializers[data_type].objectify(buff, self, nullable)
+            result = self.deserializers[data_type].objectify(buff, self, nullable)
+        if self.pdt_registry is not None and isinstance(result, ProviderDefinedType):
+            hydrated = self.pdt_registry.hydrate(result)
+            if not isinstance(hydrated, ProviderDefinedType):
+                return hydrated
+            result = hydrated
+        if isinstance(result, ProviderDefinedType) and result.name in _pdt_decorated_types:
+            return self._hydrate_decorated(result)
+        return result
+
+    def _hydrate_decorated(self, pdt):
+        """Hydrate a ProviderDefinedType using a @provider_defined decorated class."""
+        cls = _pdt_decorated_types[pdt.name]
+        fields = {}
+        for k, v in pdt.fields.items():
+            if isinstance(v, ProviderDefinedType) and v.name in _pdt_decorated_types:
+                fields[k] = self._hydrate_decorated(v)
+            elif self.pdt_registry is not None and isinstance(v, ProviderDefinedType):
+                fields[k] = self.pdt_registry.hydrate(v)
+            else:
+                fields[k] = v
+        obj = cls.__new__(cls)
+        for k, v in fields.items():
+            setattr(obj, k, v)
+        return obj
 
 
 class _GraphBinaryTypeIO(object, metaclass=GraphBinaryTypeType):
@@ -921,4 +947,26 @@ class MarkerIO(_GraphBinaryTypeIO):
     def objectify(cls, buff, reader, nullable=True):
         return cls.is_null(buff, reader,
                            lambda b, r: Marker.of(int8_unpack(b.read(1))),
-                           nullable)
\ No newline at end of file
+                           nullable)
+
+
+class ProviderDefinedTypeIO(_GraphBinaryTypeIO):
+    python_type = ProviderDefinedType
+    graphbinary_type = DataType.composite_pdt
+
+    @classmethod
+    def dictify(cls, obj, writer, to_extend, as_value=False, nullable=True):
+        cls.prefix_bytes(cls.graphbinary_type, as_value, nullable, to_extend)
+        StringIO.dictify(obj.name, writer, to_extend)
+        MapIO.dictify(obj.fields, writer, to_extend)
+        return to_extend
+
+    @classmethod
+    def objectify(cls, buff, reader, nullable=True):
+        return cls.is_null(buff, reader, cls._read_pdt, nullable)
+
+    @classmethod
+    def _read_pdt(cls, b, r):
+        name = r.read_object(b)
+        fields = r.read_object(b)
+        return ProviderDefinedType(name, fields)
\ No newline at end of file
diff --git a/gremlin-python/src/main/python/tests/integration/conftest.py b/gremlin-python/src/main/python/tests/integration/conftest.py
index 76215bf24c..de685fb4b9 100644
--- a/gremlin-python/src/main/python/tests/integration/conftest.py
+++ b/gremlin-python/src/main/python/tests/integration/conftest.py
@@ -18,6 +18,7 @@
 #
 
 import concurrent.futures
+from collections import namedtuple
 from json import dumps
 import os
 import ssl
@@ -40,6 +41,14 @@ basic_url = gremlin_basic_auth_url.format(45941)
 
 verbose_logging = False
 
+# Shared namedtuple used by remote_connection_with_registry fixture and its tests.
+RegistryPoint = namedtuple('RegistryPoint', ['x', 'y'])
+
+
+@pytest.fixture
+def registry_point_class():
+    return RegistryPoint
+
 logging.basicConfig(format='%(asctime)s [%(levelname)8s] [%(filename)15s:%(lineno)d - %(funcName)10s()] - %(message)s',
                     level=logging.DEBUG if verbose_logging else logging.INFO)
 
@@ -211,6 +220,27 @@ def client_with_interceptor(request):
         return client
 
 
+@pytest.fixture
+def remote_connection_with_registry(request):
+    from gremlin_python.structure.graph import ProviderDefinedTypeRegistry
+
+    registry = ProviderDefinedTypeRegistry()
+    registry.register('RegistryPoint',
+                      deserialize_fn=lambda fields: RegistryPoint(x=fields['x'], y=fields['y']),
+                      serialize_fn=lambda p: {'x': p.x, 'y': p.y},
+                      target_class=RegistryPoint)
+    try:
+        remote_conn = DriverRemoteConnection(anonymous_url, 'gmodern', pdt_registry=registry)
+    except OSError:
+        pytest.skip('Gremlin Server is not running')
+    else:
+        def fin():
+            remote_conn.close()
+
+        request.addfinalizer(fin)
+        return remote_conn
+
+
 def json_interceptor(request):
         request['headers']['content-type'] = "application/json"
         request['payload'] = dumps({"gremlin": "g.inject(2)", "g": "g"})
diff --git a/gremlin-python/src/main/python/tests/integration/driver/test_client.py b/gremlin-python/src/main/python/tests/integration/driver/test_client.py
index 00e427cd58..34fa004e12 100644
--- a/gremlin-python/src/main/python/tests/integration/driver/test_client.py
+++ b/gremlin-python/src/main/python/tests/integration/driver/test_client.py
@@ -26,6 +26,7 @@ from gremlin_python.driver.client import Client
 from gremlin_python.driver.connection import GremlinServerError
 from gremlin_python.driver.request import RequestMessage
 from gremlin_python.driver.serializer import GraphBinarySerializersV4
+from gremlin_python.structure.graph import ProviderDefinedType
 from gremlin_python.process.graph_traversal import __, GraphTraversalSource
 from gremlin_python.process.traversal import TraversalStrategies, GValue
 from gremlin_python.process.strategies import OptionsStrategy
@@ -554,3 +555,60 @@ def test_response_serializer_never_None():
 def test_serializer_and_interceptor_forwarded(client_with_interceptor):
     result = client_with_interceptor.submit("g.inject(1)").next()
     assert 2 == result # interceptor changes request to g.inject(2)
+
+def test_simple_pdt_round_trip(client):
+    """Inject and retrieve a simple Point PDT."""
+    results = client.submit(
+        "g.inject(PDT(\"Point\", [\"x\":1, \"y\":2]))"
+    ).all().result()
+
+    assert len(results) == 1
+    pdt = results[0]
+    assert isinstance(pdt, ProviderDefinedType)
+    assert pdt.name == 'Point'
+    assert pdt.fields['x'] == 1
+    assert pdt.fields['y'] == 2
+
+
+def test_nested_pdt(client):
+    """Inject and retrieve a nested PDT (Person containing Address)."""
+    results = client.submit(
+        "g.inject(PDT(\"Person\", [\"name\":\"Alice\", \"age\":30, "
+        "\"address\":PDT(\"Address\", [\"street\":\"123 Main St\", \"city\":\"Springfield\", \"zip\":\"12345\"])]))"
+    ).all().result()
+
+    assert len(results) == 1
+    pdt = results[0]
+    assert isinstance(pdt, ProviderDefinedType)
+    assert pdt.name == 'Person'
+    assert pdt.fields['name'] == 'Alice'
+    assert pdt.fields['age'] == 30
+
+    address = pdt.fields['address']
+    assert isinstance(address, ProviderDefinedType)
+    assert address.name == 'Address'
+    assert address.fields['street'] == '123 Main St'
+    assert address.fields['city'] == 'Springfield'
+    assert address.fields['zip'] == '12345'
+
+
+def test_pdt_in_collection(client):
+    """Retrieve multiple PDTs as a list."""
+    results = client.submit(
+        "g.inject([PDT(\"Point\", [\"x\":1, \"y\":2]), PDT(\"Point\", [\"x\":3, \"y\":4])])"
+    ).all().result()
+
+    assert len(results) == 1
+    pdt_list = results[0]
+    assert isinstance(pdt_list, list)
+    assert len(pdt_list) == 2
+
+    assert isinstance(pdt_list[0], ProviderDefinedType)
+    assert pdt_list[0].name == 'Point'
+    assert pdt_list[0].fields['x'] == 1
+    assert pdt_list[0].fields['y'] == 2
+
+    assert isinstance(pdt_list[1], ProviderDefinedType)
+    assert pdt_list[1].name == 'Point'
+    assert pdt_list[1].fields['x'] == 3
+    assert pdt_list[1].fields['y'] == 4
diff --git a/gremlin-python/src/main/python/tests/integration/driver/test_driver_remote_connection.py b/gremlin-python/src/main/python/tests/integration/driver/test_driver_remote_connection.py
index 604190b0c6..cf853d43b8 100644
--- a/gremlin-python/src/main/python/tests/integration/driver/test_driver_remote_connection.py
+++ b/gremlin-python/src/main/python/tests/integration/driver/test_driver_remote_connection.py
@@ -26,7 +26,7 @@ from gremlin_python.statics import long
 from gremlin_python.process.traversal import TraversalStrategy, P, Order, T, DT, GValue, Cardinality, Scope
 from gremlin_python.process.graph_traversal import __
 from gremlin_python.process.anonymous_traversal import traversal
-from gremlin_python.structure.graph import Vertex, Edge, Graph
+from gremlin_python.structure.graph import Vertex, Edge, Graph, ProviderDefinedType, provider_defined
 from gremlin_python.process.strategies import SubgraphStrategy, SeedStrategy, ReservedKeysVerificationStrategy
 from gremlin_python.structure.io.util import HashableDict
 from gremlin_python.driver.connection import GremlinServerError
@@ -289,3 +289,34 @@ class TestDriverRemoteConnection(object):
         g = traversal().with_(remote_connection_with_interceptor)
         result = g.inject(1).next()
         assert 2 == result # interceptor changes request to g.inject(2)
+
+    def test_pdt_round_trip_via_traversal(self, remote_connection):
+        g = traversal().with_(remote_connection)
+        pdt = ProviderDefinedType('Point', {'x': 1, 'y': 2})
+        result = g.inject(pdt).next()
+        assert isinstance(result, ProviderDefinedType)
+        assert result.name == 'Point'
+        assert result.fields == {'x': 1, 'y': 2}
+
+    def test_pdt_registry_round_trip_via_traversal(self, remote_connection_with_registry, registry_point_class):
+        g = traversal().with_(remote_connection_with_registry)
+        point = registry_point_class(x=10, y=20)
+        result = g.inject(point).next()
+        # Registry auto-dehydrates on send and auto-hydrates on receive
+        assert isinstance(result, registry_point_class)
+        assert result.x == 10
+        assert result.y == 20
+
+    def test_pdt_annotation_auto_dehydrate_via_traversal(self, remote_connection):
+        @provider_defined(name='TestPoint')
+        class TestPoint:
+            def __init__(self, x, y):
+                self.x = x
+                self.y = y
+
+        g = traversal().with_(remote_connection)
+        point = TestPoint(5, 10)
+        result = g.inject(point).next()
+        assert isinstance(result, TestPoint)
+        assert result.x == 5
+        assert result.y == 10
diff --git a/gremlin-python/src/main/python/tests/unit/process/test_gremlin_lang.py b/gremlin-python/src/main/python/tests/unit/process/test_gremlin_lang.py
index edf0515317..a55b98e5c8 100644
--- a/gremlin-python/src/main/python/tests/unit/process/test_gremlin_lang.py
+++ b/gremlin-python/src/main/python/tests/unit/process/test_gremlin_lang.py
@@ -579,3 +579,79 @@ class TestGremlinLang(object):
         result = GremlinLang.convert_parameters_to_string({'name': "it's a test"})
         assert "'name'" in result
         assert "it" in result
+
+    def test_provider_defined_auto_dehydration(self):
+        from gremlin_python.structure.graph import ProviderDefinedType, provider_defined
+        g = traversal().with_(None)
+
+        @provider_defined(name="com.example.Point")
+        class Point:
+            def __init__(self, x, y):
+                self.x = x
+                self.y = y
+
+        p = Point(1, 2)
+        gremlin = g.inject(p).gremlin_lang.get_gremlin()
+        assert "PDT('com.example.Point',['x':1,'y':2])" in gremlin
+
+    def test_pdt_adapter_takes_precedence_over_decorator(self):
+        from gremlin_python.structure.graph import ProviderDefinedTypeRegistry, provider_defined
+        g = traversal().with_(None)
+
+        @provider_defined(name="com.example.Point")
+        class Point:
+            def __init__(self, x, y):
+                self.x = x
+                self.y = y
+
+        registry = ProviderDefinedTypeRegistry()
+        registry.register("com.adapter.Point",
+                          deserialize_fn=lambda fields: Point(fields["a"], fields["b"]),
+                          serialize_fn=lambda p: {"a": p.x, "b": p.y},
+                          target_class=Point)
+        g.gremlin_lang.pdt_registry = registry
+
+        p = Point(3, 4)
+        gremlin = g.inject(p).gremlin_lang.get_gremlin()
+        # The adapter's type_name and fields must win over the decorator's
+        assert "PDT('com.adapter.Point',['a':3,'b':4])" in gremlin
+
+    def test_pdt_special_characters_in_name(self):
+        from gremlin_python.structure.graph import ProviderDefinedType
+        g = traversal().with_(None)
+
+        pdt = ProviderDefinedType('say"hello"', {'v': 1})
+        gremlin = g.inject(pdt).gremlin_lang.get_gremlin()
+        assert "PDT('say\"hello\"',['v':1])" in gremlin
+
+        pdt2 = ProviderDefinedType('back\\slash', {'v': 1})
+        gremlin2 = g.inject(pdt2).gremlin_lang.get_gremlin()
+        assert "PDT('back\\\\slash',['v':1])" in gremlin2
+
+    def test_pdt_nested(self):
+        from gremlin_python.structure.graph import ProviderDefinedType
+        g = traversal().with_(None)
+
+        inner = ProviderDefinedType('Inner', {'v': 1})
+        outer = ProviderDefinedType('Outer', {'inner': inner})
+        gremlin = g.inject(outer).gremlin_lang.get_gremlin()
+        assert "PDT('Outer',['inner':PDT('Inner',['v':1])])" in gremlin
+
+    def test_dehydrate_inner_decorated_in_unregistered_outer(self):
+        """A @provider_defined inner object must dehydrate to PDT form even when nested
+        as a field value inside a raw (unregistered/undecorated) ProviderDefinedType."""
+        from gremlin_python.structure.graph import ProviderDefinedType, provider_defined
+        g = traversal().with_(None)
+
+        @provider_defined(name="com.example.Inner")
+        class Inner:
+            def __init__(self, val):
+                self.val = val
+
+        inner_obj = Inner(99)
+        # Outer is a raw ProviderDefinedType — no adapter, no decorator
+        outer_pdt = ProviderDefinedType("com.example.Outer", {"child": inner_obj, "count": 7})
+
+        gremlin = g.inject(outer_pdt).gremlin_lang.get_gremlin()
+        # The inner decorated object MUST be dehydrated to its PDT representation
+        assert "PDT('com.example.Outer',['child':PDT('com.example.Inner',['val':99]),'count':7])" in gremlin
diff --git a/gremlin-python/src/main/python/tests/unit/structure/io/test_graphbinaryV4.py b/gremlin-python/src/main/python/tests/unit/structure/io/test_graphbinaryV4.py
index c1f2ac540e..f7c8c7aa51 100644
--- a/gremlin-python/src/main/python/tests/unit/structure/io/test_graphbinaryV4.py
+++ b/gremlin-python/src/main/python/tests/unit/structure/io/test_graphbinaryV4.py
@@ -23,7 +23,7 @@ from collections import OrderedDict
 
 from datetime import datetime, timedelta, timezone
 from gremlin_python.statics import long, bigint, BigDecimal, SingleByte, SingleChar
-from gremlin_python.structure.graph import Graph, Vertex, Edge, Property, VertexProperty, Path
+from gremlin_python.structure.graph import Graph, Vertex, Edge, Property, VertexProperty, Path, ProviderDefinedType
 from gremlin_python.structure.io.graphbinaryV4 import GraphBinaryWriter, GraphBinaryReader
 from gremlin_python.process.traversal import Direction
 from gremlin_python.structure.io.util import Marker
@@ -316,3 +316,25 @@ class TestGraphBinaryV4(object):
         assert len(re1.properties) == 1
         assert re1.properties[0].key == "weight"
         assert re1.properties[0].value == 0.5
+
+    def test_provider_defined_type(self):
+        pdt = ProviderDefinedType('Point', {'x': 1, 'y': 2})
+        result = self.graphbinary_reader.read_object(self.graphbinary_writer.write_object(pdt))
+        assert isinstance(result, ProviderDefinedType)
+        assert result.name == 'Point'
+        assert result.fields == {'x': 1, 'y': 2}
+
+    def test_provider_defined_type_nested(self):
+        inner = ProviderDefinedType('Address', {'street': 'Main'})
+        outer = ProviderDefinedType('Person', {'name': 'Alice', 'address': inner})
+        result = self.graphbinary_reader.read_object(self.graphbinary_writer.write_object(outer))
+        assert result.name == 'Person'
+        assert result.fields['name'] == 'Alice'
+        assert isinstance(result.fields['address'], ProviderDefinedType)
+        assert result.fields['address'].name == 'Address'
+
+    def test_provider_defined_type_null_field(self):
+        pdt = ProviderDefinedType('NullableType', {'value': None, 'name': 'test'})
+        result = self.graphbinary_reader.read_object(self.graphbinary_writer.write_object(pdt))
+        assert result.fields['value'] is None
+        assert result.fields['name'] == 'test'
diff --git a/gremlin-python/src/main/python/tests/unit/structure/io/test_provider_defined_type.py b/gremlin-python/src/main/python/tests/unit/structure/io/test_provider_defined_type.py
new file mode 100644
index 0000000000..aac685982c
--- /dev/null
+++ b/gremlin-python/src/main/python/tests/unit/structure/io/test_provider_defined_type.py
@@ -0,0 +1,244 @@
+"""
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+  http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+"""
+
+import pytest
+
+from gremlin_python.structure.graph import ProviderDefinedType, ProviderDefinedTypeRegistry, provider_defined
+from gremlin_python.structure.io.graphbinaryV4 import GraphBinaryWriter, GraphBinaryReader
+
+
+class TestProviderDefinedType(object):
+    graphbinary_writer = GraphBinaryWriter()
+    graphbinary_reader = GraphBinaryReader()
+
+    def test_empty_name_rejected(self):
+        with pytest.raises(ValueError):
+            ProviderDefinedType("", {"x": 1})
+
+    def test_none_name_rejected(self):
+        with pytest.raises(ValueError):
+            ProviderDefinedType(None, {"x": 1})
+
+    def test_non_string_key_rejected(self):
+        with pytest.raises(TypeError):
+            ProviderDefinedType("com.example.Bad", {1: "value"})
+
+
+class TestProviderDefinedTypeRegistry(object):
+
+    def test_hydrate_simple(self):
+        registry = ProviderDefinedTypeRegistry()
+        registry.register("com.example.Point", lambda fields: (fields["x"], fields["y"]))
+        pdt = ProviderDefinedType("com.example.Point", {"x": 1.0, "y": 2.0})
+        result = registry.hydrate(pdt)
+        assert result == (1.0, 2.0)
+
+    def test_hydrate_no_adapter_returns_raw(self):
+        registry = ProviderDefinedTypeRegistry()
+        pdt = ProviderDefinedType("com.example.Unknown", {"a": 1})
+        result = registry.hydrate(pdt)
+        assert result is pdt
+
+    def test_hydrate_adapter_throws_falls_back(self):
+        registry = ProviderDefinedTypeRegistry()
+        registry.register("com.example.Bad", lambda fields: 1 / 0)
+        pdt = ProviderDefinedType("com.example.Bad", {"x": 1})
+        result = registry.hydrate(pdt)
+        assert result is pdt
+
+    def test_hydrate_nested(self):
+        from collections import namedtuple
+        Inner = namedtuple("Inner", ["val"])
+        Outer = namedtuple("Outer", ["child", "count"])
+        registry = ProviderDefinedTypeRegistry()
+        registry.register("com.example.Inner", lambda fields: Inner(fields["val"].upper()))
+        registry.register("com.example.Outer", lambda fields: Outer(fields["child"], fields["count"]))
+        inner = ProviderDefinedType("com.example.Inner", {"val": "hello"})
+        outer = ProviderDefinedType("com.example.Outer", {"child": inner, "count": 42})
+        result = registry.hydrate(outer)
+        assert result == Outer(Inner("HELLO"), 42)
+
+    def test_hydrate_non_pdt_passthrough(self):
+        registry = ProviderDefinedTypeRegistry()
+        assert registry.hydrate("plain string") == "plain string"
+        assert registry.hydrate(42) == 42
+
+    def test_dehydrate_simple(self):
+        from collections import namedtuple
+        Point = namedtuple("Point", ["x", "y"])
+        registry = ProviderDefinedTypeRegistry()
+        registry.register("com.example.Point",
+                          deserialize_fn=lambda fields: Point(fields["x"], fields["y"]),
+                          serialize_fn=lambda p: {"x": p.x, "y": p.y},
+                          target_class=Point)
+        adapter = registry.get_adapter_by_class(Point)
+        fields = adapter['serialize'](Point(1.0, 2.0))
+        assert fields == {"x": 1.0, "y": 2.0}
+
+    def test_dehydrate_no_adapter_returns_none(self):
+        registry = ProviderDefinedTypeRegistry()
+        assert registry.get_adapter_by_class(str) is None
+
+    def test_dehydrate_no_serialize_fn_returns_none(self):
+        registry = ProviderDefinedTypeRegistry()
+        registry.register("com.example.Thing", deserialize_fn=lambda fields: fields, target_class=dict)
+        adapter = registry.get_adapter_by_class(dict)
+        assert adapter['serialize'] is None
+
+    def test_hydrate_inner_registered_in_unregistered_outer(self):
+        """A registered type ALWAYS hydrates even when nested inside an unregistered outer PDT."""
+        from collections import namedtuple
+        Inner = namedtuple("Inner", ["val"])
+        registry = ProviderDefinedTypeRegistry()
+        registry.register("com.example.Inner", lambda fields: Inner(fields["val"]))
+        # "com.example.Outer" is intentionally NOT registered
+        inner_pdt = ProviderDefinedType("com.example.Inner", {"val": 42})
+        outer_pdt = ProviderDefinedType("com.example.Outer", {"child": inner_pdt, "count": 7})
+
+        result = registry.hydrate(outer_pdt)
+
+        # Outer stays raw ProviderDefinedType (no adapter)
+        assert isinstance(result, ProviderDefinedType)
+        assert result.name == "com.example.Outer"
+        # Inner field MUST be hydrated to Inner(val=42)
+        assert result.fields["child"] == Inner(val=42)
+        # Non-PDT fields pass through unchanged
+        assert result.fields["count"] == 7
+
+
+class TestProviderDefinedTypeRegistryBuild(object):
+
+    def test_build_returns_registry_with_no_entry_points(self):
+        registry = ProviderDefinedTypeRegistry.create()
+        assert isinstance(registry, ProviderDefinedTypeRegistry)
+
+    def test_build_loads_entry_point(self):
+        from unittest.mock import patch, MagicMock
+
+        mock_ep = MagicMock()
+        mock_ep.name = "mock_adapter"
+        mock_ep.load.return_value = lambda reg: reg.register("com.mock.Type", lambda fields: fields)
+
+        with patch("importlib.metadata.entry_points") as mock_entry_points:
+            import sys
+            if sys.version_info >= (3, 10):
+                mock_entry_points.return_value = [mock_ep]
+            else:
+                mock_entry_points.return_value = {'tinkerpop.pdt': [mock_ep]}
+
+            registry = ProviderDefinedTypeRegistry.create()
+            assert "com.mock.Type" in registry._adapters_by_name
+
+    def test_build_handles_failing_entry_point(self):
+        from unittest.mock import patch, MagicMock
+
+        mock_ep = MagicMock()
+        mock_ep.name = "bad_adapter"
+        mock_ep.load.side_effect = RuntimeError("boom")
+
+        with patch("importlib.metadata.entry_points") as mock_entry_points:
+            import sys
+            if sys.version_info >= (3, 10):
+                mock_entry_points.return_value = [mock_ep]
+            else:
+                mock_entry_points.return_value = {'tinkerpop.pdt': [mock_ep]}
+
+            registry = ProviderDefinedTypeRegistry.create()
+            assert isinstance(registry, ProviderDefinedTypeRegistry)
+            assert len(registry._adapters_by_name) == 0
+
+
+class TestReaderAutoHydration(object):
+
+    def test_reader_auto_hydrates_with_registry(self):
+        registry = ProviderDefinedTypeRegistry()
+        registry.register("com.example.Point", lambda fields: {"x": fields["x"], "y": fields["y"], "hydrated": True})
+        writer = GraphBinaryWriter()
+        reader = GraphBinaryReader(pdt_registry=registry)
+
+        pdt = ProviderDefinedType("com.example.Point", {"x": 1.0, "y": 2.0})
+        result = reader.read_object(writer.write_object(pdt))
+        assert result == {"x": 1.0, "y": 2.0, "hydrated": True}
+
+    def test_reader_no_registry_returns_raw_pdt(self):
+        writer = GraphBinaryWriter()
+        reader = GraphBinaryReader()
+
+        pdt = ProviderDefinedType("com.example.Unregistered", {"x": 1.0, "y": 2.0})
+        result = reader.read_object(writer.write_object(pdt))
+        assert isinstance(result, ProviderDefinedType)
+        assert result == pdt
+
+
+class TestProviderDefinedDecorator(object):
+
+    def test_decorator_sets_metadata_with_name(self):
+        @provider_defined(name="com.example.Point", included_fields=["x", "y"])
+        class Point:
+            pass
+
+        assert Point._pdt_name == "com.example.Point"
+        assert Point._pdt_included_fields == ["x", "y"]
+        assert Point._pdt_excluded_fields is None
+
+    def test_decorator_defaults_to_class_name(self):
+        @provider_defined()
+        class MyType:
+            pass
+
+        assert MyType._pdt_name == "MyType"
+        assert MyType._pdt_included_fields is None
+        assert MyType._pdt_excluded_fields is None
+
+    def test_decorator_excluded_fields(self):
+        @provider_defined(excluded_fields=["internal"])
+        class Foo:
+            pass
+
+        assert Foo._pdt_excluded_fields == ["internal"]
+
+
+class TestPdtRegistryWiring(object):
+
+    def test_serializer_passes_registry_to_reader(self):
+        pytest.importorskip("aiohttp")
+        from gremlin_python.driver.serializer import GraphBinarySerializersV4
+        registry = ProviderDefinedTypeRegistry()
+        s = GraphBinarySerializersV4(pdt_registry=registry)
+        assert s._graphbinary_reader.pdt_registry is registry
+
+    def test_client_passes_registry_to_serializers(self):
+        pytest.importorskip("aiohttp")
+        from unittest.mock import patch
+        from gremlin_python.driver.client import Client
+        registry = ProviderDefinedTypeRegistry()
+        with patch.object(Client, '_fill_pool'):
+            c = Client("ws://localhost:8182/gremlin", "g", pdt_registry=registry)
+            assert c._request_serializer._graphbinary_reader.pdt_registry is registry
+            assert c._response_serializer._graphbinary_reader.pdt_registry is registry
+
+    def test_driver_remote_connection_passes_registry(self):
+        pytest.importorskip("aiohttp")
+        from unittest.mock import patch
+        from gremlin_python.driver.client import Client
+        from gremlin_python.driver.driver_remote_connection import DriverRemoteConnection
+        registry = ProviderDefinedTypeRegistry()
+        with patch.object(Client, '_fill_pool'):
+            drc = DriverRemoteConnection("ws://localhost:8182/gremlin", "g", pdt_registry=registry)
+            assert drc._client._response_serializer._graphbinary_reader.pdt_registry is registry
diff --git a/gremlin-server/pom.xml b/gremlin-server/pom.xml
index df4c5e6da6..016365f6b1 100644
--- a/gremlin-server/pom.xml
+++ b/gremlin-server/pom.xml
@@ -173,6 +173,22 @@ limitations under the License.
                     </archive>
                 </configuration>
             </plugin>
+            <plugin>
+                <!--
+                The test-jar is consumed by docker/gremlin-test-server/Dockerfile, which copies
+                gremlin-server-*-tests.jar onto the test server's classpath so that the PDT test
+                fixtures (org.apache.tinkerpop.gremlin.server.pdt.{Point,Person,Address}) are
+                available to the GLV integration tests.
+                -->
+                <artifactId>maven-jar-plugin</artifactId>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>test-jar</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-surefire-plugin</artifactId>
diff --git a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/GremlinDriverIntegrateTest.java b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/GremlinDriverIntegrateTest.java
index 14b185142d..dd277cf8db 100644
--- a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/GremlinDriverIntegrateTest.java
+++ b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/GremlinDriverIntegrateTest.java
@@ -30,10 +30,17 @@ import org.apache.tinkerpop.gremlin.driver.ResultSet;
 import org.apache.tinkerpop.gremlin.driver.exception.NoHostAvailableException;
 import org.apache.tinkerpop.gremlin.driver.exception.ResponseException;
 import org.apache.tinkerpop.gremlin.driver.interceptor.PayloadSerializingInterceptor;
+import org.apache.tinkerpop.gremlin.driver.remote.DriverRemoteConnection;
 import org.apache.tinkerpop.gremlin.jsr223.ScriptFileGremlinPlugin;
+import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource;
 import org.apache.tinkerpop.gremlin.server.channel.HttpChannelizer;
 import org.apache.tinkerpop.gremlin.structure.Vertex;
 import org.apache.tinkerpop.gremlin.structure.io.Storage;
+import org.apache.tinkerpop.gremlin.structure.io.binary.TypeSerializerRegistry;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefined;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeAdapter;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeRegistry;
 import org.apache.tinkerpop.gremlin.structure.util.detached.DetachedVertex;
 import org.apache.tinkerpop.gremlin.util.ExceptionHelper;
 import org.apache.tinkerpop.gremlin.util.TimeUtil;
@@ -68,6 +75,7 @@ import java.util.concurrent.atomic.AtomicReference;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 
+import static org.apache.tinkerpop.gremlin.process.traversal.AnonymousTraversalSource.traversal;
 import static org.hamcrest.CoreMatchers.is;
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.core.IsInstanceOf.instanceOf;
@@ -1264,4 +1272,156 @@ public class GremlinDriverIntegrateTest extends AbstractGremlinServerIntegration
             cluster.close();
         }
     }
+
+    @Test
+    public void shouldRoundTripRawPdtViaTraversal() throws Exception {
+        final Cluster cluster = TestClientFactory.build().create();
+        try {
+            final GraphTraversalSource g = traversal().with(DriverRemoteConnection.using(cluster));
+            final Map<String, Object> fields = new HashMap<>();
+            fields.put("x", 1);
+            fields.put("y", 2);
+            final ProviderDefinedType pdt = new ProviderDefinedType("TestPoint", fields);
+            final Object result = g.inject(pdt).next();
+
+            assertTrue(result instanceof ProviderDefinedType);
+            final ProviderDefinedType r = (ProviderDefinedType) result;
+            assertEquals("TestPoint", r.getName());
+            assertEquals(1, r.getFields().get("x"));
+            assertEquals(2, r.getFields().get("y"));
+        } finally {
+            cluster.close();
+        }
+    }
+
+    @Test
+    public void shouldRoundTripRegistryPdtViaTraversal() throws Exception {
+        final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+        registry.register(new TestPointAdapter());
+
+        final Cluster cluster = TestClientFactory.build()
+                .serializer(new GraphBinaryMessageSerializerV4(TypeSerializerRegistry.INSTANCE, registry))
+                .create();
+        try {
+            final DriverRemoteConnection connection = DriverRemoteConnection.using(cluster);
+            connection.setPdtRegistry(registry);
+            final GraphTraversalSource g = traversal().with(connection);
+
+            final Object result = g.inject(new TestPoint(5, 10)).next();
+
+            assertTrue("Expected TestPoint but got: " + result.getClass().getName(), result instanceof TestPoint);
+            assertEquals(5, ((TestPoint) result).x);
+            assertEquals(10, ((TestPoint) result).y);
+        } finally {
+            cluster.close();
+        }
+    }
+
+    @Test
+    public void shouldRoundTripAnnotatedPdtViaTraversal() throws Exception {
+        final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+        registry.register(TestAnnotatedPoint.class);
+
+        final Cluster cluster = TestClientFactory.build()
+                .serializer(new GraphBinaryMessageSerializerV4(TypeSerializerRegistry.INSTANCE, registry))
+                .create();
+        try {
+            final DriverRemoteConnection connection = DriverRemoteConnection.using(cluster);
+            connection.setPdtRegistry(registry);
+            final GraphTraversalSource g = traversal().with(connection);
+
+            final Object result = g.inject(new TestAnnotatedPoint(3, 7)).next();
+
+            assertTrue("Expected TestAnnotatedPoint but got: " + result.getClass().getName(),
+                    result instanceof TestAnnotatedPoint);
+            assertEquals(3, ((TestAnnotatedPoint) result).x);
+            assertEquals(7, ((TestAnnotatedPoint) result).y);
+        } finally {
+            cluster.close();
+        }
+    }
+
+    @Test
+    public void shouldHydratePdtViaRegistryFromDriverResult() throws Exception {
+        final Cluster cluster = TestClientFactory.build().create();
+        final Client client = cluster.connect();
+        try {
+            final List<Result> results = client.submit(
+                    "g.inject(new org.apache.tinkerpop.gremlin.server.pdt.Point(9, 11))",
+                    groovyRequestOptions).all().get();
+
+            assertEquals(1, results.size());
+            final ProviderDefinedType raw = (ProviderDefinedType) results.get(0).getObject();
+
+            final ProviderDefinedTypeRegistry registry = ProviderDefinedTypeRegistry.empty();
+            registry.register(new TestPointAdapter());
+            final Object hydrated = registry.hydrate(raw);
+
+            assertTrue(hydrated instanceof TestPoint);
+            assertEquals(9, ((TestPoint) hydrated).x);
+            assertEquals(11, ((TestPoint) hydrated).y);
+        } finally {
+            cluster.close();
+        }
+    }
+
+    @Test
+    public void shouldStorePdtAsOriginalObjectInTinkerGraph() throws Exception {
+        final Cluster cluster = TestClientFactory.build().create();
+        final Client client = cluster.connect();
+        try {
+            client.submit(
+                    "g.addV('location').property('point', new org.apache.tinkerpop.gremlin.server.pdt.Point(3, 4)).iterate()",
+                    groovyRequestOptions).all().get();
+
+            final List<Result> results = client.submit(
+                    "g.V().hasLabel('location').values('point')",
+                    groovyRequestOptions).all().get();
+
+            assertEquals(1, results.size());
+            // Point is @ProviderDefined, so GraphBinary serializes the stored object as a ProviderDefinedType
+            // on the wire. Without a client-side registry it is received as a raw ProviderDefinedType.
+            final Object value = results.get(0).getObject();
+            assertTrue("Expected ProviderDefinedType but got: " + value.getClass().getName(),
+                    value instanceof ProviderDefinedType);
+            final ProviderDefinedType pdt = (ProviderDefinedType) value;
+            assertEquals("Point", pdt.getName());
+            assertEquals(3, pdt.getFields().get("x"));
+            assertEquals(4, pdt.getFields().get("y"));
+        } finally {
+            // cleanup
+            client.submit("g.V().hasLabel('location').drop().iterate()", groovyRequestOptions).all().get();
+            cluster.close();
+        }
+    }
+
+    // --- PDT helper types ---
+
+    static class TestPoint {
+        final int x, y;
+        TestPoint(final int x, final int y) { this.x = x; this.y = y; }
+    }
+
+    static class TestPointAdapter implements ProviderDefinedTypeAdapter<TestPoint> {
+        // TestPoint is the client-side representation of the server-side @ProviderDefined "Point" type,
+        // so the adapter's type name matches the server type name "Point".
+        @Override public String typeName() { return "Point"; }
+        @Override public Class<TestPoint> targetClass() { return TestPoint.class; }
+        @Override public Map<String, Object> toFields(final TestPoint obj) {
+            final Map<String, Object> m = new HashMap<>();
+            m.put("x", obj.x);
+            m.put("y", obj.y);
+            return m;
+        }
+        @Override public TestPoint fromFields(final Map<String, Object> fields) {
+            return new TestPoint(((Number) fields.get("x")).intValue(), ((Number) fields.get("y")).intValue());
+        }
+    }
+
+    @ProviderDefined(name = "TestAnnotatedPoint")
+    static class TestAnnotatedPoint {
+        public int x, y;
+        public TestAnnotatedPoint() {}
+        TestAnnotatedPoint(final int x, final int y) { this.x = x; this.y = y; }
+    }
 }
diff --git a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/GremlinServerSerializationIntegrateTest.java b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/GremlinServerSerializationIntegrateTest.java
index 300b1e1e74..746184fab4 100644
--- a/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/GremlinServerSerializationIntegrateTest.java
+++ b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/GremlinServerSerializationIntegrateTest.java
@@ -18,9 +18,17 @@
  */
 package org.apache.tinkerpop.gremlin.server;
 
+import org.apache.http.Consts;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.apache.http.util.EntityUtils;
 import org.apache.tinkerpop.gremlin.driver.Client;
 import org.apache.tinkerpop.gremlin.driver.Cluster;
 import org.apache.tinkerpop.gremlin.driver.RequestOptions;
+import org.apache.tinkerpop.gremlin.driver.Result;
 import org.apache.tinkerpop.gremlin.driver.remote.DriverRemoteConnection;
 import org.apache.tinkerpop.gremlin.process.traversal.AnonymousTraversalSource;
 import org.apache.tinkerpop.gremlin.process.traversal.Path;
@@ -29,10 +37,14 @@ import org.apache.tinkerpop.gremlin.structure.Edge;
 import org.apache.tinkerpop.gremlin.structure.Property;
 import org.apache.tinkerpop.gremlin.structure.Vertex;
 import org.apache.tinkerpop.gremlin.structure.VertexProperty;
+import org.apache.tinkerpop.gremlin.structure.io.graphson.GraphSONTokens;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType;
 import org.apache.tinkerpop.gremlin.util.Tokens;
 import org.apache.tinkerpop.gremlin.util.iterator.IteratorUtils;
 import org.apache.tinkerpop.gremlin.util.ser.AbstractMessageSerializer;
 import org.apache.tinkerpop.gremlin.util.ser.GraphBinaryMessageSerializerV4;
+import org.apache.tinkerpop.shaded.jackson.databind.JsonNode;
+import org.apache.tinkerpop.shaded.jackson.databind.ObjectMapper;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -42,6 +54,7 @@ import org.junit.runners.Parameterized;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.List;
 
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.core.Is.is;
@@ -257,6 +270,79 @@ public class GremlinServerSerializationIntegrateTest extends AbstractGremlinServ
         assertEquals(0.5, edge.property("weight").value());
     }
 
+    @Test
+    public void shouldRoundTripSimplePointPdt() throws Exception {
+        final List<Result> results = client.submit(
+                "g.inject(PDT(\"Point\", [\"x\":1, \"y\":2]))").all().get();
+
+        assertEquals(1, results.size());
+        final ProviderDefinedType pdt = (ProviderDefinedType) results.get(0).getObject();
+        assertEquals("Point", pdt.getName());
+        assertEquals(1, pdt.getFields().get("x"));
+        assertEquals(2, pdt.getFields().get("y"));
+    }
+
+    @Test
+    public void shouldRoundTripNestedPdt() throws Exception {
+        final List<Result> results = client.submit(
+                "g.inject(PDT(\"Person\", [\"name\":\"Alice\", \"age\":30, " +
+                "\"address\":PDT(\"Address\", [\"street\":\"123 Main St\", \"city\":\"Springfield\", \"zip\":\"12345\"])]))").all().get();
+
+        assertEquals(1, results.size());
+        final ProviderDefinedType person = (ProviderDefinedType) results.get(0).getObject();
+        assertEquals("Person", person.getName());
+        assertEquals("Alice", person.getFields().get("name"));
+        assertEquals(30, person.getFields().get("age"));
+
+        final ProviderDefinedType address = (ProviderDefinedType) person.getFields().get("address");
+        assertEquals("Address", address.getName());
+        assertEquals("123 Main St", address.getFields().get("street"));
+        assertEquals("Springfield", address.getFields().get("city"));
+    }
+
+    @Test
+    public void shouldRoundTripPdtInCollection() throws Exception {
+        final List<Result> results = client.submit(
+                "g.inject([PDT(\"Point\", [\"x\":1, \"y\":2]), PDT(\"Point\", [\"x\":3, \"y\":4])])").all().get();
+
+        assertEquals(1, results.size());
+        final List<?> list = (List<?>) results.get(0).getObject();
+        assertEquals(2, list.size());
+
+        final ProviderDefinedType p1 = (ProviderDefinedType) list.get(0);
+        assertEquals("Point", p1.getName());
+        assertEquals(1, p1.getFields().get("x"));
+        assertEquals(2, p1.getFields().get("y"));
+
+        final ProviderDefinedType p2 = (ProviderDefinedType) list.get(1);
+        assertEquals("Point", p2.getName());
+        assertEquals(3, p2.getFields().get("x"));
+        assertEquals(4, p2.getFields().get("y"));
+    }
+
+    @Test
+    public void shouldReturnPdtAsGraphSONCompositePdtInHttpResponse() throws Exception {
+        final CloseableHttpClient httpclient = HttpClients.createDefault();
+        final HttpPost httppost = new HttpPost(TestClientFactory.createURLString());
+        httppost.addHeader("Content-Type", "application/json");
+        httppost.addHeader("Accept", "application/json");
+        httppost.setEntity(new StringEntity(
+                "{\"gremlin\":\"g.inject(org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType.from(new org.apache.tinkerpop.gremlin.server.pdt.Point(1, 2)))\",\"language\":\"gremlin-groovy\"}",
+                Consts.UTF_8));
+
+        try (final CloseableHttpResponse response = httpclient.execute(httppost)) {
+            assertEquals(200, response.getStatusLine().getStatusCode());
+            final JsonNode root = new ObjectMapper().readTree(EntityUtils.toString(response.getEntity()));
+            final JsonNode pdtNode = root.get("result").get("data").get(GraphSONTokens.VALUEPROP).get(0);
+
+            assertEquals("g:CompositePdt", pdtNode.get("@type").asText());
+            final JsonNode value = pdtNode.get(GraphSONTokens.VALUEPROP);
+            assertEquals("Point", value.get("type").asText());
+            assertEquals(1, value.get("fields").get("x").get(GraphSONTokens.VALUEPROP).intValue());
+            assertEquals(2, value.get("fields").get("y").get(GraphSONTokens.VALUEPROP).intValue());
+        }
+    }
+
     private void assertPathElementsWithProperties(final Path p) {
         // expect a V-E-V path
         assertEquals(3, p.size());
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/types/CustomTypeSerializer.java b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/pdt/Address.java
similarity index 66%
copy from gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/types/CustomTypeSerializer.java
copy to gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/pdt/Address.java
index a54580cab0..cc01cdd8a7 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/types/CustomTypeSerializer.java
+++ b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/pdt/Address.java
@@ -16,17 +16,19 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.tinkerpop.gremlin.structure.io.binary.types;
+package org.apache.tinkerpop.gremlin.server.pdt;
 
-import org.apache.tinkerpop.gremlin.structure.io.binary.TypeSerializer;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefined;
 
-/**
- * Represents a serializer for a custom (provider specific) serializer.
- * @param <T>
- */
-public interface CustomTypeSerializer<T> extends TypeSerializer<T> {
-    /**
-     * Gets the custom type name.
-     */
-    String getTypeName();
+@ProviderDefined(name = "Address")
+public class Address {
+    public String street;
+    public String city;
+    public String zip;
+
+    public Address(final String street, final String city, final String zip) {
+        this.street = street;
+        this.city = city;
+        this.zip = zip;
+    }
 }
diff --git a/gremlin-util/src/test/java/org/apache/tinkerpop/gremlin/util/ser/binary/types/sample/SamplePerson.java b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/pdt/Person.java
similarity index 57%
rename from gremlin-util/src/test/java/org/apache/tinkerpop/gremlin/util/ser/binary/types/sample/SamplePerson.java
rename to gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/pdt/Person.java
index 2af646b6de..96b878f950 100644
--- a/gremlin-util/src/test/java/org/apache/tinkerpop/gremlin/util/ser/binary/types/sample/SamplePerson.java
+++ b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/pdt/Person.java
@@ -16,31 +16,19 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.tinkerpop.gremlin.util.ser.binary.types.sample;
+package org.apache.tinkerpop.gremlin.server.pdt;
 
-import java.time.OffsetDateTime;
-import java.util.Objects;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefined;
 
-/**
- * A sample custom data type containing few properties.
- */
-public class SamplePerson {
-    private final String name;
-    private final OffsetDateTime birthDate;
-
-    SamplePerson(final String name, final OffsetDateTime birthDate) {
-        Objects.requireNonNull(name);
-        Objects.requireNonNull(birthDate);
+@ProviderDefined(name = "Person")
+public class Person {
+    public String name;
+    public int age;
+    public Address address;
 
+    public Person(final String name, final int age, final Address address) {
         this.name = name;
-        this.birthDate = birthDate;
-    }
-
-    public String getName() {
-        return name;
-    }
-
-    public OffsetDateTime getBirthDate() {
-        return birthDate;
+        this.age = age;
+        this.address = address;
     }
 }
diff --git a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/types/CustomTypeSerializer.java b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/pdt/Point.java
similarity index 67%
rename from gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/types/CustomTypeSerializer.java
rename to gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/pdt/Point.java
index a54580cab0..1bb8470061 100644
--- a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/binary/types/CustomTypeSerializer.java
+++ b/gremlin-server/src/test/java/org/apache/tinkerpop/gremlin/server/pdt/Point.java
@@ -16,17 +16,20 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-package org.apache.tinkerpop.gremlin.structure.io.binary.types;
+package org.apache.tinkerpop.gremlin.server.pdt;
 
-import org.apache.tinkerpop.gremlin.structure.io.binary.TypeSerializer;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefined;
 
 /**
- * Represents a serializer for a custom (provider specific) serializer.
- * @param <T>
+ * A simple test class annotated with {@link ProviderDefined} for PDT integration testing.
  */
-public interface CustomTypeSerializer<T> extends TypeSerializer<T> {
-    /**
-     * Gets the custom type name.
-     */
-    String getTypeName();
+@ProviderDefined(name = "Point")
+public class Point {
+    public int x;
+    public int y;
+
+    public Point(final int x, final int y) {
+        this.x = x;
+        this.y = y;
+    }
 }
diff --git a/gremlin-util/src/main/java/org/apache/tinkerpop/gremlin/util/ser/GraphBinaryMessageSerializerV4.java b/gremlin-util/src/main/java/org/apache/tinkerpop/gremlin/util/ser/GraphBinaryMessageSerializerV4.java
index 5ad6c8f04e..6f2e4c657f 100644
--- a/gremlin-util/src/main/java/org/apache/tinkerpop/gremlin/util/ser/GraphBinaryMessageSerializerV4.java
+++ b/gremlin-util/src/main/java/org/apache/tinkerpop/gremlin/util/ser/GraphBinaryMessageSerializerV4.java
@@ -23,24 +23,20 @@ import io.netty.buffer.ByteBufAllocator;
 import io.netty.handler.codec.http.HttpResponseStatus;
 import org.apache.tinkerpop.gremlin.structure.Graph;
 import org.apache.tinkerpop.gremlin.structure.io.Buffer;
-import org.apache.tinkerpop.gremlin.structure.io.IoRegistry;
-import org.apache.tinkerpop.gremlin.structure.io.binary.GraphBinaryIo;
 import org.apache.tinkerpop.gremlin.structure.io.binary.GraphBinaryMapper;
 import org.apache.tinkerpop.gremlin.structure.io.binary.GraphBinaryReader;
 import org.apache.tinkerpop.gremlin.structure.io.binary.GraphBinaryWriter;
 import org.apache.tinkerpop.gremlin.structure.io.binary.Marker;
 import org.apache.tinkerpop.gremlin.structure.io.binary.TypeSerializerRegistry;
-import org.apache.tinkerpop.gremlin.structure.io.binary.types.CustomTypeSerializer;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeRegistry;
 import org.apache.tinkerpop.gremlin.util.message.RequestMessage;
 import org.apache.tinkerpop.gremlin.util.message.ResponseMessage;
 import org.apache.tinkerpop.gremlin.util.message.ResponseStatus;
 import org.apache.tinkerpop.gremlin.util.ser.binary.RequestMessageSerializer;
-import org.javatuples.Pair;
 import org.javatuples.Triplet;
 
 import java.io.IOException;
 import java.lang.reflect.Constructor;
-import java.lang.reflect.Method;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.EnumSet;
@@ -48,7 +44,6 @@ import java.util.List;
 import java.util.Map;
 
 public class GraphBinaryMessageSerializerV4 extends AbstractMessageSerializer<GraphBinaryMapper> {
-    public static final String TOKEN_CUSTOM = "custom";
     public static final String TOKEN_BUILDER = "builder";
 
     private GraphBinaryReader reader;
@@ -60,14 +55,19 @@ public class GraphBinaryMessageSerializerV4 extends AbstractMessageSerializer<Gr
     private static final String MIME_TYPE = SerTokens.MIME_GRAPHBINARY_V4;
 
     /**
-     * Creates a new instance of the message serializer using the default type serializers.
+     * Creates a new instance of the message serializer using the default type serializers and
+     * an SPI-discovered {@link ProviderDefinedTypeRegistry} for automatic PDT hydration.
      */
     public GraphBinaryMessageSerializerV4() {
-        this(TypeSerializerRegistry.INSTANCE);
+        this(TypeSerializerRegistry.INSTANCE, ProviderDefinedTypeRegistry.create());
     }
 
     public GraphBinaryMessageSerializerV4(final TypeSerializerRegistry registry) {
-        reader = new GraphBinaryReader(registry);
+        this(registry, ProviderDefinedTypeRegistry.create());
+    }
+
+    public GraphBinaryMessageSerializerV4(final TypeSerializerRegistry registry, final ProviderDefinedTypeRegistry pdtRegistry) {
+        reader = new GraphBinaryReader(registry, pdtRegistry);
         writer = new GraphBinaryWriter(registry);
         mapper = new GraphBinaryMapper(writer, reader);
 
@@ -100,29 +100,8 @@ public class GraphBinaryMessageSerializerV4 extends AbstractMessageSerializer<Gr
             builder = TypeSerializerRegistry.build();
         }
 
-        final List<String> classNameList = getListStringFromConfig(TOKEN_IO_REGISTRIES, config);
-        classNameList.forEach(className -> {
-            try {
-                final Class<?> clazz = Class.forName(className);
-                try {
-                    final Method instanceMethod = tryInstanceMethod(clazz);
-                    final IoRegistry ioreg = (IoRegistry) instanceMethod.invoke(null);
-                    final List<Pair<Class, CustomTypeSerializer>> classSerializers = ioreg.find(GraphBinaryIo.class, CustomTypeSerializer.class);
-                    for (Pair<Class,CustomTypeSerializer> cs : classSerializers) {
-                        builder.addCustomType(cs.getValue0(), cs.getValue1());
-                    }
-                } catch (Exception methodex) {
-                    throw new IllegalStateException(String.format("Could not instantiate IoRegistry from an instance() method on %s", className), methodex);
-                }
-            } catch (Exception ex) {
-                throw new IllegalStateException(ex);
-            }
-        });
-
-        addCustomClasses(config, builder);
-
         final TypeSerializerRegistry registry = builder.create();
-        reader = new GraphBinaryReader(registry);
+        reader = new GraphBinaryReader(registry, ProviderDefinedTypeRegistry.create());
         writer = new GraphBinaryWriter(registry);
 
         requestSerializer = new RequestMessageSerializer();
@@ -133,34 +112,6 @@ public class GraphBinaryMessageSerializerV4 extends AbstractMessageSerializer<Gr
         return new String[] {MIME_TYPE};
     }
 
-    private void addCustomClasses(final Map<String, Object> config, final TypeSerializerRegistry.Builder builder) {
-        final List<String> classNameList = getListStringFromConfig(TOKEN_CUSTOM, config);
-
-        classNameList.forEach(serializerDefinition -> {
-            final String className;
-            final String serializerName;
-            if (serializerDefinition.contains(";")) {
-                final String[] split = serializerDefinition.split(";");
-                if (split.length != 2)
-                    throw new IllegalStateException(String.format("Invalid format for serializer definition [%s] - expected <class>;<serializer-class>", serializerDefinition));
-
-                className = split[0];
-                serializerName = split[1];
-            } else {
-                throw new IllegalStateException(String.format("Invalid format for serializer definition [%s] - expected <class>;<serializer-class>", serializerDefinition));
-            }
-
-            try {
-                final Class clazz = Class.forName(className);
-                final Class serializerClazz = Class.forName(serializerName);
-                final CustomTypeSerializer serializer = (CustomTypeSerializer) serializerClazz.newInstance();
-                builder.addCustomType(clazz, serializer);
-            } catch (Exception ex) {
-                throw new IllegalStateException("CustomTypeSerializer could not be instantiated", ex);
-            }
-        });
-    }
-
     @Override
     public ByteBuf serializeRequestAsBinary(RequestMessage requestMessage, ByteBufAllocator allocator) throws SerializationException {
         final ByteBuf buffer = allocator.buffer();
diff --git a/gremlin-util/src/test/java/org/apache/tinkerpop/gremlin/util/ser/binary/GraphBinaryPdtSpiAutoWiringTest.java b/gremlin-util/src/test/java/org/apache/tinkerpop/gremlin/util/ser/binary/GraphBinaryPdtSpiAutoWiringTest.java
new file mode 100644
index 0000000000..b356b82776
--- /dev/null
+++ b/gremlin-util/src/test/java/org/apache/tinkerpop/gremlin/util/ser/binary/GraphBinaryPdtSpiAutoWiringTest.java
@@ -0,0 +1,134 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.util.ser.binary;
+
+import io.netty.buffer.ByteBuf;
+import io.netty.buffer.ByteBufAllocator;
+import org.apache.tinkerpop.gremlin.structure.io.binary.TypeSerializerRegistry;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeRegistry;
+import org.apache.tinkerpop.gremlin.util.message.ResponseMessage;
+import org.apache.tinkerpop.gremlin.util.ser.GraphBinaryMessageSerializerV4;
+import org.apache.tinkerpop.gremlin.util.ser.SerializationException;
+import org.junit.Test;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Validates that the default {@link GraphBinaryMessageSerializerV4} auto-wires SPI-discovered
+ * {@link org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeAdapter} implementations
+ * for hydration without any user configuration.
+ */
+public class GraphBinaryPdtSpiAutoWiringTest {
+
+    private final ByteBufAllocator allocator = ByteBufAllocator.DEFAULT;
+
+    @Test
+    public void shouldAutoHydrateSpiDiscoveredAdapterWithDefaultSerializer() throws SerializationException {
+        // The default no-arg constructor should pick up TestPointAdapter via SPI
+        final GraphBinaryMessageSerializerV4 serializer = new GraphBinaryMessageSerializerV4();
+
+        // Build a response containing a raw ProviderDefinedType matching the test adapter's typeName
+        final Map<String, Object> fields = new LinkedHashMap<>();
+        fields.put("x", 10);
+        fields.put("y", 20);
+        final ProviderDefinedType pdt = new ProviderDefinedType("test.Point", fields);
+
+        final ResponseMessage response = ResponseMessage.build()
+                .result(Collections.singletonList(pdt))
+                .create();
+
+        // Serialize and deserialize — the reader should hydrate the PDT into TestPoint
+        final ByteBuf buffer = serializer.writeHeader(response, allocator);
+        try {
+            final ResponseMessage deserialized = serializer.readChunk(buffer, true);
+            assertNotNull(deserialized.getResult().getData());
+            assertEquals(1, deserialized.getResult().getData().size());
+
+            final Object result = deserialized.getResult().getData().get(0);
+            assertTrue("Expected hydrated TestPoint but got: " + result.getClass().getName(),
+                    result instanceof TestPointAdapter.TestPoint);
+
+            final TestPointAdapter.TestPoint point = (TestPointAdapter.TestPoint) result;
+            assertEquals(10, point.x);
+            assertEquals(20, point.y);
+        } finally {
+            buffer.release();
+        }
+    }
+
+    @Test
+    public void shouldAutoHydrateSpiDiscoveredAdapterAfterConfigure() throws SerializationException {
+        // Verify configure() also includes the SPI registry
+        final GraphBinaryMessageSerializerV4 serializer = new GraphBinaryMessageSerializerV4();
+        serializer.configure(Collections.emptyMap(), Collections.emptyMap());
+
+        final Map<String, Object> fields = new LinkedHashMap<>();
+        fields.put("x", 5);
+        fields.put("y", 15);
+        final ProviderDefinedType pdt = new ProviderDefinedType("test.Point", fields);
+
+        final ResponseMessage response = ResponseMessage.build()
+                .result(Collections.singletonList(pdt))
+                .create();
+
+        final ByteBuf buffer = serializer.writeHeader(response, allocator);
+        try {
+            final ResponseMessage deserialized = serializer.readChunk(buffer, true);
+            final Object result = deserialized.getResult().getData().get(0);
+            assertTrue("Expected hydrated TestPoint after configure()", result instanceof TestPointAdapter.TestPoint);
+        } finally {
+            buffer.release();
+        }
+    }
+
+    @Test
+    public void shouldUseExplicitPdtRegistryOverSpiDefault() throws SerializationException {
+        // An empty registry should NOT hydrate since it has no adapters registered
+        final ProviderDefinedTypeRegistry emptyRegistry = ProviderDefinedTypeRegistry.empty();
+        final GraphBinaryMessageSerializerV4 serializer = new GraphBinaryMessageSerializerV4(
+                TypeSerializerRegistry.INSTANCE, emptyRegistry);
+
+        final Map<String, Object> fields = new LinkedHashMap<>();
+        fields.put("x", 1);
+        fields.put("y", 2);
+        final ProviderDefinedType pdt = new ProviderDefinedType("test.Point", fields);
+
+        final ResponseMessage response = ResponseMessage.build()
+                .result(Collections.singletonList(pdt))
+                .create();
+
+        final ByteBuf buffer = serializer.writeHeader(response, allocator);
+        try {
+            final ResponseMessage deserialized = serializer.readChunk(buffer, true);
+            final Object result = deserialized.getResult().getData().get(0);
+            // With empty registry, should remain as raw ProviderDefinedType (no hydration)
+            assertTrue("Expected raw ProviderDefinedType with explicit empty registry but got: " + result.getClass().getName(),
+                    result instanceof ProviderDefinedType);
+        } finally {
+            buffer.release();
+        }
+    }
+}
diff --git a/gremlin-util/src/test/java/org/apache/tinkerpop/gremlin/util/ser/binary/GraphBinaryWriterPdtTest.java b/gremlin-util/src/test/java/org/apache/tinkerpop/gremlin/util/ser/binary/GraphBinaryWriterPdtTest.java
new file mode 100644
index 0000000000..e4483934d5
--- /dev/null
+++ b/gremlin-util/src/test/java/org/apache/tinkerpop/gremlin/util/ser/binary/GraphBinaryWriterPdtTest.java
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.util.ser.binary;
+
+import io.netty.buffer.ByteBufAllocator;
+import org.apache.tinkerpop.gremlin.structure.io.Buffer;
+import org.apache.tinkerpop.gremlin.structure.io.binary.GraphBinaryReader;
+import org.apache.tinkerpop.gremlin.structure.io.binary.GraphBinaryWriter;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefined;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType;
+import org.apache.tinkerpop.gremlin.util.ser.NettyBufferFactory;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+public class GraphBinaryWriterPdtTest {
+
+    private static final GraphBinaryReader reader = new GraphBinaryReader();
+    private static final GraphBinaryWriter writer = new GraphBinaryWriter();
+    private static final ByteBufAllocator allocator = ByteBufAllocator.DEFAULT;
+    private static final NettyBufferFactory bufferFactory = new NettyBufferFactory();
+
+    @ProviderDefined
+    static class TestPoint {
+        int x;
+        int y;
+
+        TestPoint(int x, int y) {
+            this.x = x;
+            this.y = y;
+        }
+    }
+
+    static class UnannotatedType {
+        int value = 1;
+    }
+
+    @Test
+    public void shouldAutoConvertAnnotatedObjectToPdt() throws IOException {
+        final Buffer buffer = bufferFactory.create(allocator.buffer());
+        writer.write(new TestPoint(1, 2), buffer);
+        buffer.readerIndex(0);
+
+        final ProviderDefinedType result = reader.read(buffer);
+        assertEquals("TestPoint", result.getName());
+        assertEquals(1, result.getFields().get("x"));
+        assertEquals(2, result.getFields().get("y"));
+    }
+
+    @Test
+    public void shouldThrowActionableMessageForUnannotatedType() {
+        final Buffer buffer = bufferFactory.create(allocator.buffer());
+        final IOException ex = assertThrows(IOException.class, () -> writer.write(new UnannotatedType(), buffer));
+        assertTrue(ex.getMessage().contains("@ProviderDefined"));
+        assertTrue(ex.getMessage().contains("UnannotatedType"));
+    }
+
+    @Test
+    public void shouldNotDoubleWrapProviderDefinedType() throws IOException {
+        final Map<String, Object> fields = new LinkedHashMap<>();
+        fields.put("x", 1);
+        fields.put("y", 2);
+        final ProviderDefinedType pdt = new ProviderDefinedType("TestPoint", fields);
+
+        final Buffer buffer = bufferFactory.create(allocator.buffer());
+        writer.write(pdt, buffer);
+        buffer.readerIndex(0);
+
+        final ProviderDefinedType result = reader.read(buffer);
+        assertEquals(pdt, result);
+    }
+}
diff --git a/gremlin-util/src/test/java/org/apache/tinkerpop/gremlin/util/ser/binary/TestPointAdapter.java b/gremlin-util/src/test/java/org/apache/tinkerpop/gremlin/util/ser/binary/TestPointAdapter.java
new file mode 100644
index 0000000000..6a352f274c
--- /dev/null
+++ b/gremlin-util/src/test/java/org/apache/tinkerpop/gremlin/util/ser/binary/TestPointAdapter.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.util.ser.binary;
+
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeAdapter;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Test-only adapter registered via META-INF/services for SPI auto-wiring validation.
+ */
+public class TestPointAdapter implements ProviderDefinedTypeAdapter<TestPointAdapter.TestPoint> {
+
+    public static class TestPoint {
+        public final int x;
+        public final int y;
+
+        public TestPoint(final int x, final int y) {
+            this.x = x;
+            this.y = y;
+        }
+    }
+
+    @Override
+    public String typeName() {
+        return "test.Point";
+    }
+
+    @Override
+    public Class<TestPoint> targetClass() {
+        return TestPoint.class;
+    }
+
+    @Override
+    public Map<String, Object> toFields(final TestPoint obj) {
+        final Map<String, Object> fields = new LinkedHashMap<>();
+        fields.put("x", obj.x);
+        fields.put("y", obj.y);
+        return fields;
+    }
+
+    @Override
+    public TestPoint fromFields(final Map<String, Object> fields) {
+        return new TestPoint(((Number) fields.get("x")).intValue(), ((Number) fields.get("y")).intValue());
+    }
+}
diff --git a/gremlin-util/src/test/java/org/apache/tinkerpop/gremlin/util/ser/binary/TypeSerializerRegistryTest.java b/gremlin-util/src/test/java/org/apache/tinkerpop/gremlin/util/ser/binary/TypeSerializerRegistryTest.java
index aaba1987a9..c5618c4eb9 100644
--- a/gremlin-util/src/test/java/org/apache/tinkerpop/gremlin/util/ser/binary/TypeSerializerRegistryTest.java
+++ b/gremlin-util/src/test/java/org/apache/tinkerpop/gremlin/util/ser/binary/TypeSerializerRegistryTest.java
@@ -18,8 +18,6 @@
  */
 package org.apache.tinkerpop.gremlin.util.ser.binary;
 
-import org.apache.tinkerpop.gremlin.util.ser.binary.types.sample.SamplePerson;
-import org.apache.tinkerpop.gremlin.util.ser.binary.types.sample.SamplePersonSerializer;
 import org.apache.tinkerpop.gremlin.structure.io.binary.DataType;
 import org.apache.tinkerpop.gremlin.structure.io.binary.GraphBinaryReader;
 import org.apache.tinkerpop.gremlin.structure.io.binary.GraphBinaryWriter;
@@ -93,18 +91,18 @@ public class TypeSerializerRegistryTest {
 
         String message = null;
         try {
-            registry.getSerializer(SamplePerson.class);
+            registry.getSerializer(StringBuilder.class);
         } catch (IOException ex) {
             message = ex.getMessage();
         }
 
-        assertEquals("Serializer for type org.apache.tinkerpop.gremlin.util.ser.binary.types.sample.SamplePerson not found", message);
+        assertEquals("Serializer not found for type java.lang.StringBuilder. If this is a provider-defined type, annotate the class with @ProviderDefined.", message);
         assertEquals(1, called[0]);
     }
 
     @Test
     public void shouldUseFallbackResolverReturnValue() throws IOException {
-        TypeSerializer expected = new SamplePersonSerializer();
+        TypeSerializer expected = new TestUUIDSerializer();
         final int[] called = {0};
         final TypeSerializerRegistry registry = TypeSerializerRegistry.build()
                 .withFallbackResolver(t -> {
@@ -112,7 +110,7 @@ public class TypeSerializerRegistryTest {
                     return expected;
                 }).create();
 
-        TypeSerializer<SamplePerson> serializer = registry.getSerializer(SamplePerson.class);
+        TypeSerializer<StringBuilder> serializer = registry.getSerializer(StringBuilder.class);
         assertEquals(1, called[0]);
         assertSame(expected, serializer);
     }
diff --git a/gremlin-util/src/test/java/org/apache/tinkerpop/gremlin/util/ser/binary/types/ProviderDefinedTypeSerializerTest.java b/gremlin-util/src/test/java/org/apache/tinkerpop/gremlin/util/ser/binary/types/ProviderDefinedTypeSerializerTest.java
new file mode 100644
index 0000000000..1294e82113
--- /dev/null
+++ b/gremlin-util/src/test/java/org/apache/tinkerpop/gremlin/util/ser/binary/types/ProviderDefinedTypeSerializerTest.java
@@ -0,0 +1,232 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.tinkerpop.gremlin.util.ser.binary.types;
+
+import io.netty.buffer.ByteBufAllocator;
+import org.apache.tinkerpop.gremlin.structure.io.Buffer;
+import org.apache.tinkerpop.gremlin.structure.io.binary.GraphBinaryReader;
+import org.apache.tinkerpop.gremlin.structure.io.binary.GraphBinaryWriter;
+import org.apache.tinkerpop.gremlin.structure.io.binary.TypeSerializerRegistry;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedType;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeAdapter;
+import org.apache.tinkerpop.gremlin.structure.io.pdt.ProviderDefinedTypeRegistry;
+import org.apache.tinkerpop.gremlin.util.ser.NettyBufferFactory;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+public class ProviderDefinedTypeSerializerTest {
+
+    private static final GraphBinaryReader reader = new GraphBinaryReader();
+    private static final GraphBinaryWriter writer = new GraphBinaryWriter();
+    private static final ByteBufAllocator allocator = ByteBufAllocator.DEFAULT;
+    private static final NettyBufferFactory bufferFactory = new NettyBufferFactory();
+
+    private Buffer writeAndRead(final Object value) throws IOException {
+        final Buffer buffer = bufferFactory.create(allocator.buffer());
+        writer.write(value, buffer);
+        buffer.readerIndex(0);
+        return buffer;
+    }
+
+    @Test
+    public void shouldRoundTripSimplePdt() throws IOException {
+        final Map<String, Object> fields = new LinkedHashMap<>();
+        fields.put("x", 1);
+        fields.put("y", "hello");
+        final ProviderDefinedType pdt = new ProviderDefinedType("com.example.Point", fields);
+
+        final Buffer buffer = writeAndRead(pdt);
+        final ProviderDefinedType result = reader.read(buffer);
+
+        assertEquals(pdt, result);
+    }
+
+    @Test
+    public void shouldRoundTripPdtWithNullFieldValue() throws IOException {
+        final Map<String, Object> fields = new LinkedHashMap<>();
+        fields.put("name", "test");
+        fields.put("value", null);
+        final ProviderDefinedType pdt = new ProviderDefinedType("com.example.Nullable", fields);
+
+        final Buffer buffer = writeAndRead(pdt);
+        final ProviderDefinedType result = reader.read(buffer);
+
+        assertEquals(pdt, result);
+    }
+
+    @Test
+    public void shouldRoundTripNestedPdt() throws IOException {
+        final Map<String, Object> innerFields = new LinkedHashMap<>();
+        innerFields.put("street", "123 Main");
+        final ProviderDefinedType inner = new ProviderDefinedType("com.example.Address", innerFields);
+
+        final Map<String, Object> outerFields = new LinkedHashMap<>();
+        outerFields.put("name", "Alice");
+        outerFields.put("address", inner);
+        final ProviderDefinedType outer = new ProviderDefinedType("com.example.Person", outerFields);
+
+        final Buffer buffer = writeAndRead(outer);
+        final ProviderDefinedType result = reader.read(buffer);
+
+        assertEquals(outer, result);
+    }
+
+    @Test
+    public void shouldRoundTripPdtInsideList() throws IOException {
+        final Map<String, Object> fields = Collections.singletonMap("id", 42);
+        final ProviderDefinedType pdt = new ProviderDefinedType("com.example.Item", fields);
+        final List<Object> list = Arrays.asList(pdt, "other");
+
+        final Buffer buffer = writeAndRead(list);
+        final List<Object> result = reader.read(buffer);
+
+        assertEquals(list, result);
+    }
+
+    @Test
+    public void shouldRoundTripPdtInsideMapValue() throws IOException {
+        final Map<String, Object> fields = Collections.singletonMap("val", 99L);
+        final ProviderDefinedType pdt = new ProviderDefinedType("com.example.Wrapper", fields);
+        final Map<String, Object> map = new HashMap<>();
+        map.put("key", pdt);
+
+        final Buffer buffer = writeAndRead(map);
+        final Map<String, Object> result = reader.read(buffer);
+
+        assertEquals(map, result);
+    }
+
+    @Test(expected = IOException.class)
+    public void shouldThrowOnEmptyNameDuringRead() throws IOException {
+        final Buffer buffer = bufferFactory.create(allocator.buffer());
+        // Write type code for COMPOSITE_PDT
+        buffer.writeByte(0xF0);
+        // Write value_flag = 0 (not null)
+        buffer.writeByte(0x00);
+        // Write a fully-qualified empty string: type_code STRING (0x03), value_flag 0, length 0
+        buffer.writeByte(0x03);
+        buffer.writeByte(0x00);
+        buffer.writeInt(0);
+        // Write a fully-qualified map: type_code MAP (0x0A), value_flag 0, length 0
+        buffer.writeByte(0x0A);
+        buffer.writeByte(0x00);
+        buffer.writeInt(0);
+
+        buffer.readerIndex(0);
+        reader.read(buffer);
+    }
+
+    @Test(expected = IOException.class)
+    public void shouldThrowOnNonStringKeyInFieldsMap() throws IOException {
+        final Buffer buffer = bufferFactory.create(allocator.buffer());
+        // Write type code for COMPOSITE_PDT (0xF0), value_flag 0
+        buffer.writeByte(0xF0);
+        buffer.writeByte(0x00);
+        // Write fully-qualified String name: type STRING (0x03), flag 0, length 4, "test"
+        buffer.writeByte(0x03);
+        buffer.writeByte(0x00);
+        buffer.writeInt(4);
+        buffer.writeBytes(new byte[]{'t', 'e', 's', 't'});
+        // Write fully-qualified Map: type MAP (0x0A), flag 0, length 1 (one entry)
+        buffer.writeByte(0x0A);
+        buffer.writeByte(0x00);
+        buffer.writeInt(1);
+        // Key: INT type (0x01), flag 0, value 42
+        buffer.writeByte(0x01);
+        buffer.writeByte(0x00);
+        buffer.writeInt(42);
+        // Value: STRING type (0x03), flag 0, length 3, "val"
+        buffer.writeByte(0x03);
+        buffer.writeByte(0x00);
+        buffer.writeInt(3);
+        buffer.writeBytes(new byte[]{'v', 'a', 'l'});
+
+        buffer.readerIndex(0);
+        reader.read(buffer);
+    }
+
+    @Test
+    public void shouldHandleNullPdt() throws IOException {
+        final Buffer buffer = bufferFactory.create(allocator.buffer());
+        writer.write(null, buffer);
+        buffer.readerIndex(0);
+        final Object result = reader.read(buffer);
+        assertNull(result);
+    }
+
+    @Test
+    public void shouldAutoHydrateWhenRegistryConfigured() throws IOException {
+        final ProviderDefinedTypeRegistry pdtRegistry = ProviderDefinedTypeRegistry.empty();
+        pdtRegistry.register(new ProviderDefinedTypeAdapter<Map<String, Object>>() {
+            @Override
+            public String typeName() { return "com.example.Point"; }
+
+            @Override
+            public Class<Map<String, Object>> targetClass() { return (Class) Map.class; }
+
+            @Override
+            public Map<String, Object> fromFields(final Map<String, Object> fields) {
+                final Map<String, Object> result = new LinkedHashMap<>(fields);
+                result.put("hydrated", true);
+                return result;
+            }
+
+            @Override
+            public Map<String, Object> toFields(final Map<String, Object> value) { return value; }
+        });
+
+        final GraphBinaryReader hydratingReader = new GraphBinaryReader(
+                TypeSerializerRegistry.INSTANCE, pdtRegistry);
+
+        final Map<String, Object> fields = new LinkedHashMap<>();
+        fields.put("x", 1);
+        fields.put("y", 2);
+        final ProviderDefinedType pdt = new ProviderDefinedType("com.example.Point", fields);
+
+        final Buffer buffer = writeAndRead(pdt);
+        final Object result = hydratingReader.read(buffer);
+
+        // Should be the hydrated map, not a ProviderDefinedType
+        assertEquals(true, ((Map<?, ?>) result).get("hydrated"));
+        assertEquals(1, ((Map<?, ?>) result).get("x"));
+        assertEquals(2, ((Map<?, ?>) result).get("y"));
+    }
+
+    @Test
+    public void shouldNotHydrateWhenNoRegistryConfigured() throws IOException {
+        final Map<String, Object> fields = new LinkedHashMap<>();
+        fields.put("x", 1);
+        final ProviderDefinedType pdt = new ProviderDefinedType("com.example.Point", fields);
+
+        final Buffer buffer = writeAndRead(pdt);
+        final ProviderDefinedType result = reader.read(buffer);
+
+        assertEquals(pdt, result);
+    }
+}
diff --git a/gremlin-util/src/test/java/org/apache/tinkerpop/gremlin/util/ser/binary/types/sample/SamplePersonSerializer.java b/gremlin-util/src/test/java/org/apache/tinkerpop/gremlin/util/ser/binary/types/sample/SamplePersonSerializer.java
deleted file mode 100644
index bf6c6cae79..0000000000
--- a/gremlin-util/src/test/java/org/apache/tinkerpop/gremlin/util/ser/binary/types/sample/SamplePersonSerializer.java
+++ /dev/null
@@ -1,117 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-package org.apache.tinkerpop.gremlin.util.ser.binary.types.sample;
-
-import org.apache.tinkerpop.gremlin.util.ser.SerializationException;
-import org.apache.tinkerpop.gremlin.structure.io.Buffer;
-import org.apache.tinkerpop.gremlin.structure.io.binary.DataType;
-import org.apache.tinkerpop.gremlin.structure.io.binary.GraphBinaryReader;
-import org.apache.tinkerpop.gremlin.structure.io.binary.GraphBinaryWriter;
-import org.apache.tinkerpop.gremlin.structure.io.binary.types.CustomTypeSerializer;
-
-import java.io.IOException;
-import java.nio.charset.StandardCharsets;
-import java.time.OffsetDateTime;
-
-/**
- * A sample custom type serializer.
- */
-public final class SamplePersonSerializer implements CustomTypeSerializer<SamplePerson> {
-    private final byte[] typeInfoBuffer = new byte[] { 0, 0, 0, 0 };
-
-    @Override
-    public String getTypeName() {
-        return "sampleProvider.SamplePerson";
-    }
-
-    @Override
-    public DataType getDataType() {
-        return DataType.CUSTOM;
-    }
-
-    @Override
-    public SamplePerson read(final Buffer buffer, final GraphBinaryReader context) throws IOException {
-        // {custom type info}, {value_flag} and {value}
-        // No custom_type_info
-        if (buffer.readInt() != 0) {
-            throw new SerializationException("{custom_type_info} should not be provided for this custom type");
-        }
-
-        return readValue(buffer, context, true);
-    }
-
-    @Override
-    public SamplePerson readValue(final Buffer buffer, final GraphBinaryReader context, final boolean nullable) throws IOException {
-        if (nullable) {
-            final byte valueFlag = buffer.readByte();
-            if ((valueFlag & 1) == 1) {
-                return null;
-            }
-        }
-
-        // Read the byte length of the value bytes
-        final int valueLength = buffer.readInt();
-
-        if (valueLength <= 0) {
-            throw new SerializationException(String.format("Unexpected value length: %d", valueLength));
-        }
-
-        if (valueLength > buffer.readableBytes()) {
-            throw new SerializationException(
-                String.format("Not enough readable bytes: %d (expected %d)", valueLength, buffer.readableBytes()));
-        }
-
-        final String name = context.readValue(buffer, String.class, false);
-        final OffsetDateTime birthDate = context.readValue(buffer, OffsetDateTime.class, false);
-
-        return new SamplePerson(name, birthDate);
-    }
-
-    @Override
-    public void write(final SamplePerson value, final Buffer buffer, final GraphBinaryWriter context) throws IOException {
-        // Write {custom type info}, {value_flag} and {value}
-        buffer.writeBytes(typeInfoBuffer);
-
-        writeValue(value, buffer, context, true);
-    }
-
-    @Override
-    public void writeValue(final SamplePerson value, final Buffer buffer, final GraphBinaryWriter context, final boolean nullable) throws IOException {
-        if (value == null) {
-            if (!nullable) {
... 159 lines suppressed ...