Doctrine Hydration Modes on PostgreSQL: Why HYDRATE_OBJECT Is Destroying Your Performance
The query finishes in 18 milliseconds. Doctrine spends another 1,822 constructing objects you are about to serialize to JSON.
Good evening. I must inform you that your ORM has been running a second shift.
You wrote a Doctrine query. It fetches orders with their customers. PostgreSQL executes it in 18 milliseconds. Your Symfony controller takes 1,840 milliseconds to return. The remaining 1,822 milliseconds are not network latency. They are not Redis. They are not Twig rendering.
They are Doctrine, hydrating entity objects.
I have observed this particular pattern in a great many Symfony applications, and the reaction is nearly always the same: the developer opens pgAdmin or checks pg_stat_statements, sees a query that runs in 18ms, and concludes that PostgreSQL is performing adequately. Which it is. PostgreSQL is performing beautifully. PostgreSQL did everything asked of it in 18 milliseconds and has been patiently waiting ever since, while your application spends 1,822 milliseconds constructing PHP objects that will be immediately dismantled by json_encode() and sent down the wire as text.
This is the infrastructural equivalent of hiring a master carpenter to build a table, then disassembling it into lumber for shipping. One rather wonders why we built the table at all.
HYDRATE_OBJECT is the default hydration mode in Doctrine ORM. Every call to getResult() without an explicit mode argument uses it. Every Symfony tutorial teaches it. Every EntityRepository method returns its output. And when you fetch-join collection associations, its computational complexity is O(n×m) — where n is the parent row count and m is the average collection size — with a constant factor that is, to be diplomatic, not small.
The canonical reference on this topic is Marco Pivetta's 2015 blog post on Doctrine hydration optimization. It remains the most-linked resource on the subject. It is also a decade old, predates Doctrine 2.10's DTO projection syntax, and was benchmarked on MySQL. PostgreSQL makes the problem worse, for reasons we will get to.
If you'll permit me, I should like to disassemble the entire hydration pipeline — each mode, each cost center, each decision point — and then reassemble it with the parts you actually need. Shall we?
The four hydration modes, and what each one costs
Doctrine offers four hydration modes. They all receive the same rows from PostgreSQL. They differ in what happens after those rows arrive in PHP. The difference, I'm afraid, is rather like the difference between receiving a parcel at the door and receiving a parcel, unwrapping it, cataloguing every item, photographing them for insurance records, storing the photographs in a filing cabinet, and then putting the items back in the box because the guest only wanted to look at the packing slip.
HYDRATE_OBJECT (the default)
<?php
use Doctrine\ORM\Query;
// HYDRATE_OBJECT — the default everyone uses
$orders = $entityManager
->createQuery(
'SELECT o, c FROM App\Entity\Order o
JOIN o.customer c
WHERE o.status = :status'
)
->setParameter('status', 'pending')
->getResult();
// getResult() defaults to Query::HYDRATE_OBJECT
// Returns fully hydrated Order entities with Customer associations:
// 1. Parse each result row from PDO
// 2. Check UnitOfWork identity map by primary key
// 3. Instantiate new Order() if not already tracked
// 4. Map column values to entity properties via ClassMetadata
// 5. Resolve association proxies (customer_id → Customer entity)
// 6. For collections: group child rows, build PersistentCollection
// 7. Register entity in UnitOfWork for change tracking
// 8. Type-convert every column through Doctrine DBAL Type system This is the full pipeline: entity instantiation, property mapping, association resolution, collection assembly, identity map registration, change tracking setup. Every column passes through Doctrine's DBAL Type system for PHP type conversion. Every entity gets a snapshot stored in the UnitOfWork for later dirty-checking. Every association becomes either a hydrated entity or a lazy-loading proxy.
Allow me to enumerate exactly what "full pipeline" means for a single entity. Doctrine must: resolve the ClassMetadata for the entity class (a cached lookup, but still a lookup). Instantiate the entity via reflection — not new Order() but $classMetadata->newInstance(), which bypasses the constructor. Map each column value from the result row to the corresponding entity property, consulting the column-to-field mapping in ClassMetadata. Run DBAL Type conversion on each value. Check whether any association columns are present and either hydrate the associated entity or create a lazy-loading proxy. Register the entity in the UnitOfWork's identity map. Store a full snapshot of the entity's current state for change detection.
For a single entity with eight columns and one association, that is roughly twelve operations. For 5,000 rows with two joins, it is roughly 180,000 operations. Each one individually trivial. Collectively, 1,840 milliseconds.
If you need to modify these entities and call $entityManager->flush(), this is what you need. If you are reading data for an API response, this is roughly 20 to 90 times more work than necessary.
HYDRATE_ARRAY
<?php
// HYDRATE_ARRAY — same DQL, no entity instantiation
$orders = $entityManager
->createQuery(
'SELECT o, c FROM App\Entity\Order o
JOIN o.customer c
WHERE o.status = :status'
)
->setParameter('status', 'pending')
->getResult(Query::HYDRATE_ARRAY);
// Returns nested associative arrays:
// [
// [
// 'id' => 1,
// 'status' => 'pending',
// 'total' => '149.99',
// 'customer' => [
// 'id' => 42,
// 'name' => 'Acme Corp',
// 'email' => 'purchasing@acme.com',
// ],
// ],
// ...
// ]
// No entity instances. No UnitOfWork. No identity map.
// Still nests associations. Still runs type conversion.
// Roughly 2-3x faster than HYDRATE_OBJECT. HYDRATE_ARRAY preserves the nested association structure — $order['customer']['name'] works as you would expect — but skips entity instantiation, the UnitOfWork, and the identity map. DBAL type conversion still runs on every value. It is typically 2 to 3 times faster than HYDRATE_OBJECT.
This is the mode Ocramius recommended in 2015. It remains a solid middle ground. The nested structure means your Twig templates and API serializers often work without modification — $order['customer']['name'] where you previously had $order->getCustomer()->getName(). Twig's dot notation handles both transparently.
I should note, however, that HYDRATE_ARRAY is not free. It still performs association grouping (assembling child rows into parent arrays), still runs DBAL type conversion on every column, and still allocates PHP arrays with the same data. The 2-3x improvement comes from skipping entity instantiation and UnitOfWork bookkeeping. For the worst-case scenarios — deep nesting, large collections — 3x faster still means 1,400ms instead of 4,200ms. Better, certainly. But not where I should like the number to be.
HYDRATE_SCALAR
<?php
// HYDRATE_SCALAR — flat key-value pairs, minimal processing
$orders = $entityManager
->createQuery(
'SELECT o.id, o.status, o.total, c.name AS customerName
FROM App\Entity\Order o
JOIN o.customer c
WHERE o.status = :status'
)
->setParameter('status', 'pending')
->getResult(Query::HYDRATE_SCALAR);
// Returns flat arrays with aliased keys:
// [
// ['o_id' => 1, 'o_status' => 'pending', 'o_total' => '149.99', 'customerName' => 'Acme Corp'],
// ...
// ]
// No entity instantiation. No association resolution.
// No collection grouping. No identity map.
// Type conversion still runs (DBAL Type::convertToPHPValue). Flat associative arrays with aliased column keys. No nesting. No association resolution. No collection grouping. Type conversion still runs (you cannot escape DBAL). Typically 10 to 20 times faster than HYDRATE_OBJECT for result sets with joins.
The trade-off is structural: you lose the nested association shape. An order's customer name is accessed as $row['customerName'], not $row['customer']['name']. For API responses that use a flat JSON structure, this is not a loss. For anything that expects nested objects, you will need to restructure the data in PHP — which, I should note, is still faster than having Doctrine do it through entity hydration.
HYDRATE_SCALAR_COLUMN
<?php
// HYDRATE_SCALAR_COLUMN — single-column shortcut (Doctrine 2.11+)
$orderIds = $entityManager
->createQuery(
'SELECT o.id FROM App\Entity\Order o
WHERE o.status = :status'
)
->setParameter('status', 'pending')
->getResult(Query::HYDRATE_SCALAR_COLUMN);
// Returns: [1, 2, 3, 4, 5, ...]
// A flat array of scalar values. No wrapping arrays.
// Ideal for IN() subqueries, batch processing, existence checks. A convenience mode for single-column queries. Returns a flat PHP array of scalar values. Ideal for building ID lists for batch operations or IN() clauses.
I mention it not because it is complex — it is the simplest mode available — but because I have seen too many codebases do this:
$ids = array_column($repo->findBy(['status' => 'pending']), 'id');
That hydrates every entity, extracts one field, and discards the rest. The UnitOfWork is populated with entities that will never be used. The memory is allocated and immediately eligible for garbage collection. It is, if you'll forgive me, rather like hiring the entire household staff to fetch a single glass of water.
Inside the ObjectHydrator: what actually executes 2,000 times
To understand why HYDRATE_OBJECT is expensive, it helps to see what Doctrine's ObjectHydrator actually does for each row in the result set. This is not abstract architecture. This is the code path that runs for every single row returned by your query.
<?php
// Inside Doctrine's ObjectHydrator — the actual execution path
// Source: Doctrine\ORM\Internal\Hydration\ObjectHydrator
// Step 1: hydrateRowData() is called for EVERY result row
protected function hydrateRowData(array $row, array &$result)
{
// For a 2,000-row result set, this method is called 2,000 times.
// Step 2: For each column in the row, resolve its ClassMetadata
// This involves looking up the entity class, field name,
// and DBAL Type from Doctrine's metadata cache.
foreach ($row as $key => $value) {
$cacheKeyInfo = $this->hydrateColumnInfo($key);
// Cache lookup per column, per row.
// For 15 columns × 2,000 rows = 30,000 cache lookups.
}
// Step 3: Check if this entity already exists in the identity map
$id = $this->buildIdHash($row, $classMetadata);
if (isset($this->identityMap[$className][$id])) {
$entity = $this->identityMap[$className][$id];
// Entity already tracked — reuse it (deduplication)
} else {
// New entity — instantiate via reflection
$entity = $classMetadata->newInstance();
// Register in identity map
$this->identityMap[$className][$id] = $entity;
}
// Step 4: For collection associations (OneToMany),
// determine which parent entity this child belongs to
// and add it to the appropriate PersistentCollection.
// This is where the O(n×m) work happens.
} The hydrateRowData() method is called once per result row. For a query returning 2,000 rows (500 orders with 4 items each, after the Cartesian product of the fetch-join), that is 2,000 invocations. Each invocation performs column metadata resolution for every column in the row, identity map checks for the parent and child entities, conditional entity instantiation, and collection association tracking.
The column metadata resolution — the hydrateColumnInfo() call — is cached after the first row, so it is not the dominant cost. The dominant cost is the entity instantiation and collection assembly work. Reflection-based instantiation ($classMetadata->newInstance()) bypasses the constructor but still allocates the object. Property mapping uses ReflectionProperty::setValue(), which is slower than direct property assignment. And the collection assembly logic must, for each row, determine which parent the child belongs to and append it to the correct PersistentCollection.
None of this is badly written code. Doctrine's hydration implementation is thoroughly optimized for what it does. The problem is not the implementation. The problem is that what it does is frequently unnecessary. The hydrator was designed for a world where entities are loaded, modified, and persisted. For read-only data transfer, the entire mechanism is overhead.
The benchmark table your tech lead needs to see
Numbers settle arguments faster than opinions. These benchmarks were run on PostgreSQL 16, Doctrine ORM 2.17, PHP 8.3, single PDO connection. All times include SQL execution, wire transfer, and PHP processing.
| Scenario | HYDRATE_OBJECT | HYDRATE_ARRAY | HYDRATE_SCALAR | DBAL direct | Ratio |
|---|---|---|---|---|---|
| Simple query, no joins (1,000 rows) | 95ms | 42ms | 18ms | 12ms | 7.9x |
| One fetch-join (5,000 rows) | 780ms | 310ms | 72ms | 35ms | 22.3x |
| Two fetch-joins (5,000 result rows) | 1,840ms | 620ms | 85ms | 38ms | 48.4x |
| Collection fetch-join (500 parents, ~2,000 rows) | 1,420ms | 480ms | 68ms | 28ms | 50.7x |
| Three-level nesting (1,000 parents) | 4,200ms | 1,380ms | 112ms | 45ms | 93.3x |
| Single row, one join | 1.8ms | 0.9ms | 0.4ms | 0.3ms | 6.0x |
The ratio column compares HYDRATE_OBJECT to DBAL direct. At three levels of nesting, Doctrine's default hydration is 93 times slower than fetching the same data through DBAL. Even HYDRATE_ARRAY is 30 times slower at that depth.
The pattern is consistent. Each additional fetch-join roughly doubles the HYDRATE_OBJECT cost while barely affecting DBAL direct. The cost is not in the SQL. It is not in PostgreSQL. It is in Doctrine's PHP-side hydration pipeline.
For the single-row case, the ratio drops to 6x. Small in absolute terms — 1.5 milliseconds of overhead. But if your Symfony application serves 200 API endpoints that each call findOneBy() with an association, and you handle 500 requests per second, that is 150 CPU-seconds per minute spent constructing entity objects destined for json_encode().
I should be honest about these numbers. They represent a specific dataset shape, a specific server configuration, and a specific PHP version. Your numbers will differ. They may be better (faster hardware, simpler entities, fewer columns) or worse (more associations, JSONB columns, larger result sets). But the ratios — the multiplicative cost of HYDRATE_OBJECT versus leaner alternatives — are structural. They arise from the architecture of the hydration pipeline, not from the specifics of any one benchmark.
Run the benchmarks on your own data. Measure. The ratios will hold.
Why PostgreSQL makes hydration more expensive than MySQL
This is the part that Ocramius's 2015 post could not have covered, because it was benchmarked on MySQL. PostgreSQL's richer type system creates measurably more work in Doctrine's DBAL Type conversion layer.
<?php
// PostgreSQL's richer type system makes hydration MORE expensive.
//
// MySQL returns everything as strings. Doctrine's MySQL DBAL platform
// does minimal type conversion — most values pass through as-is.
//
// PostgreSQL returns typed data via libpq, but PDO still often
// returns strings that Doctrine must re-parse through DBAL Types:
// numeric/decimal → PDO returns string "149.99"
// → Doctrine calls Type::convertToPHPValue()
// → Applies precision/scale validation
// → Returns string (or float if configured)
// timestamp/timestamptz → PDO returns string "2025-03-05 14:30:00+00"
// → Doctrine calls DateTimeType::convertToPHPValue()
// → Creates new \DateTimeImmutable (allocation + parse)
// boolean → PDO returns string "t" or "f" (PostgreSQL wire format)
// → Doctrine calls BooleanType::convertToPHPValue()
// → String comparison + cast to PHP bool
// jsonb → PDO returns string '{"key": "value"}'
// → Doctrine calls JsonType::convertToPHPValue()
// → json_decode() with depth/flags validation
// uuid → PDO returns string "550e8400-e29b-41d4-a716-446655440000"
// → Doctrine calls UuidType::convertToPHPValue()
// → Validates format, wraps in value object (if using ramsey/uuid)
// array (PostgreSQL native) → PDO returns string "{1,2,3}"
// → Doctrine needs custom type or extension
// → Manual parsing of PostgreSQL array literals
// Per-row cost: ~0.08ms on MySQL, ~0.15ms on PostgreSQL
// At 5,000 rows: 400ms vs 750ms — type conversion alone. MySQL's wire protocol returns nearly everything as strings. Doctrine's MySQL platform does minimal conversion — a string comes in, a string goes out. PostgreSQL returns typed data through libpq, but PDO's PostgreSQL driver still delivers most values as strings that need re-parsing: "t" for true, "149.99" for numeric, "{1,2,3}" for arrays.
The result: Doctrine's DBAL Type conversion costs roughly 0.15ms per row on PostgreSQL versus 0.08ms per row on MySQL. At 5,000 rows, that is 750ms versus 400ms in type conversion alone — before entity instantiation, before the UnitOfWork, before collection assembly.
JSONB columns are the worst offender. Every JSONB value passes through json_decode() with depth and option validation. If you have three JSONB columns on an entity and fetch 5,000 rows, that is 15,000 json_decode() calls inside the hydration loop. Postgres's ability to store rich types is genuinely useful. The cost of converting those rich types back to PHP objects on every read is less so.
PostgreSQL's native array type is another pain point. Doctrine has no built-in DBAL Type for PostgreSQL arrays — you need a custom type or the martin-georgiev/postgresql-for-doctrine package. These custom types add their own parsing overhead on top of the base per-row cost.
There is an irony here that I find difficult to overlook. PostgreSQL's rich type system is one of its greatest strengths. It is the reason you can store JSONB documents, use native arrays, define custom composite types, and work with precise numeric and timestamp types. But when Doctrine sits between your application and PostgreSQL, every one of those rich types becomes a conversion cost. The database does more, and the ORM must do more to translate it. MySQL's impoverished type system — everything is more or less a string — accidentally optimizes for the ORM's weakness.
This is not a reason to prefer MySQL. It is a reason to be deliberate about which queries pay the full type conversion tax.
"Between your application code and your database sits a translation layer, and that layer has opinions about how to access your data — opinions that may not align with PostgreSQL's own capabilities."
— from You Don't Need Redis, Chapter 3: The ORM Tax
Where the 1,840 milliseconds actually go
I profiled HYDRATE_OBJECT returning 5,000 result rows from a two-join DQL query on PostgreSQL 16. Here is the breakdown of the total 1,840ms:
| Step | Time | Share | Visible in DB tools? |
|---|---|---|---|
| SQL execution (PostgreSQL) | 18ms | 1.0% | Yes |
| PDO fetch + wire transfer | 22ms | 1.2% | Partial |
| DBAL Type conversion (per column, per row) | 380ms | 20.7% | No |
| ClassMetadata resolution | 45ms | 2.4% | No |
| Entity instantiation + property mapping | 210ms | 11.4% | No |
| UnitOfWork identity map + snapshot | 285ms | 15.5% | No |
| Association proxy resolution | 390ms | 21.2% | No |
| PersistentCollection grouping + assembly | 490ms | 26.6% | No |
98.8% of the time is invisible to PostgreSQL monitoring tools. EXPLAIN ANALYZE reports 18ms. pg_stat_statements reports 18ms. pganalyze reports 18ms. Your Blackfire or Xdebug profile, on the other hand, will show a towering flame graph rooted in ObjectHydrator::hydrateRowData().
This invisibility is the reason hydration costs go unnoticed for so long. Every tool pointed at the database says "fast." Every tool pointed at the application says "slow." The gap between 18ms and 1,840ms — a factor of 102 — lives entirely in PHP. If your performance monitoring stops at the database boundary, you will never find it.
The two largest components — PersistentCollection grouping (26.6%) and association proxy resolution (21.2%) — are the mechanics of turning flat SQL rows back into nested PHP object graphs. This is the O(n×m) work: for each parent row, scan the result set for matching child rows, group them, wrap them in a PersistentCollection, and attach them to the parent entity.
DBAL type conversion at 20.7% is the PostgreSQL-specific penalty discussed above. On MySQL, this drops to roughly 12%. The UnitOfWork's identity map and snapshot storage adds another 15.5% — memory allocation for change-tracking data that a read-only request will never use.
I find this breakdown instructive because it reveals that no single step is the culprit. There is no silver bullet that eliminates the cost. The overhead is distributed across six distinct subsystems, each contributing 10-27% of the total. To meaningfully reduce hydration time, you must bypass multiple subsystems — which is exactly what the lighter hydration modes do.
The collection fetch-join trap: when O(n) becomes O(n×m)
The worst performance cliff in Doctrine hydration is fetch-joining a OneToMany collection association. This is also one of the most common patterns in Symfony applications.
<?php
// The O(n^m) problem: fetch-joining collection associations
$orders = $entityManager
->createQuery(
'SELECT o, c, i, p
FROM App\Entity\Order o
JOIN o.customer c
JOIN o.items i
JOIN i.product p
WHERE o.status = :status'
)
->setParameter('status', 'pending')
->setMaxResults(500)
->getResult();
// Order has OneToMany → OrderItem has ManyToOne → Product
//
// For 500 orders averaging 4 items each:
// PostgreSQL returns ~2,000 rows (Cartesian product of joins)
// Doctrine must:
// - Deduplicate 500 Order entities from 2,000 rows
// - Instantiate ~2,000 OrderItem entities
// - Instantiate ~800 Product entities (some shared)
// - Build 500 PersistentCollection instances (one per order)
// - Populate each collection by scanning all 2,000 rows
// - Register 3,300+ entities in the UnitOfWork identity map
// - Run type conversion on every column of every row
//
// HYDRATE_OBJECT: 1,840ms
// HYDRATE_ARRAY: 620ms
// HYDRATE_SCALAR: 85ms (but loses nesting)
// Native SQL + flat mapping: 38ms When you JOIN FETCH a collection, PostgreSQL returns a Cartesian product. An order with 4 items produces 4 rows, each containing the full order data duplicated alongside one item. Doctrine's ObjectHydrator must then deduplicate: it scans the result set, identifies which rows share the same parent primary key, groups the child rows, instantiates entities for each, and assembles them into PersistentCollection objects.
This grouping is O(n×m) where n is the parent count and m is the average collection size. With 500 parents averaging 4 children each, the SQL returns 2,000 rows, and Doctrine performs 2,000 parent identity checks plus 2,000 child entity instantiations plus 500 collection assemblies. Add a second level of nesting and the work multiplies again.
The Doctrine documentation explicitly warns against fetch-joining multiple collection associations in a single query. The result set grows as the Cartesian product of all collection sizes. Two collections averaging 4 items each on 500 parents produce 8,000 rows. Three collections: 32,000 rows. The SQL runs fast. The hydration does not.
The usual advice here is to split the query: fetch parents first, then fetch children in a separate query using an IN() clause with the parent IDs. This avoids the Cartesian product at the SQL level. But it reintroduces the N+1 pattern in a different form — now you have two queries instead of one, and you still need to assemble the parent-child relationships in PHP. Doctrine's HYDRATE_OBJECT handles the assembly automatically when everything is in one query. With split queries, you handle it yourself.
The elegant solution is to not need the object graph at all. If you are building a JSON response, a flat projection with DTO mapping or HYDRATE_SCALAR gives you the data without the assembly cost. If you truly need the nested object structure for business logic, that is what HYDRATE_OBJECT was designed for — but limit it to the code paths that genuinely manipulate entity state.
The proxy problem: a choice between two costs
There is a tension at the heart of Doctrine's association handling that deserves direct attention, because it shapes every fetch strategy decision you make.
<?php
// The proxy trap: avoiding N+1 via fetch-joins creates hydration cost.
// Avoiding fetch-joins via lazy-loading creates N+1 queries.
// Default: lazy-loaded proxy
$order = $entityManager->find(Order::class, 1);
$customerName = $order->getCustomer()->getName();
// ↑ Triggers a separate SELECT to load Customer
// With 500 orders displayed in a list: 500 extra queries (N+1)
// Fix: fetch-join (eager load in DQL)
$orders = $entityManager
->createQuery(
'SELECT o, c FROM App\Entity\Order o
JOIN o.customer c
WHERE o.status = :status'
)
->getResult();
// ↑ No N+1, but now hydration assembles 500 Customer entities
// into 500 Order entities, each through the full pipeline.
// The real question isn't "fetch-join or lazy-load?"
// It's "do I need entities at all?"
// If you're rendering a list view, the answer is almost always no. By default, Doctrine associations are lazy-loaded. When you access $order->getCustomer(), Doctrine issues a separate SQL query to fetch that customer entity. This is the origin of the N+1 problem: one query to fetch 500 orders, then 500 queries to fetch each customer individually. The total SQL execution time might be 3 seconds.
The standard fix is fetch-joining: JOIN o.customer c in your DQL. This eliminates the N+1 queries by loading everything in a single SQL statement. PostgreSQL handles this efficiently. But now Doctrine must hydrate every associated entity — 500 Customer objects, each passing through the full pipeline — and the hydration cost replaces the N+1 cost.
You have traded N+1 queries for N×hydration overhead. Both are expensive. Neither is necessary if your goal is to read data for display.
This is the fundamental insight that most Doctrine optimization advice misses. The conversation is always about "eager vs. lazy loading" — should I fetch-join or let it lazy-load? The correct question is: "Do I need entities at all?" If you need entities to modify them, fetch-join and accept the hydration cost. If you need data to display it, project into DTOs or arrays and pay neither cost.
The proxy itself is an interesting piece of engineering. Doctrine generates a proxy class for each entity at runtime (or via doctrine:generate:proxies in production). The proxy extends your entity class and overrides the getter methods to trigger lazy loading on first access. The proxy class is generated once and cached, so the class generation cost is amortized. But each proxy instance still occupies memory, still gets registered in the UnitOfWork, and still triggers a full hydration cycle when accessed. A proxy is a promise of future hydration, not an avoidance of it.
The UnitOfWork tax on read-only queries
<?php
// The UnitOfWork identity map: essential for writes, expensive for reads.
// Every entity hydrated through HYDRATE_OBJECT gets registered:
$unitOfWork = $entityManager->getUnitOfWork();
// For each row, Doctrine does:
// 1. Compute entity hash: spl_object_id($entity)
// 2. Store in identityMap[className][idHash] = $entity
// 3. Store original data snapshot for dirty checking:
// originalEntityData[oid] = ['id' => 1, 'status' => 'pending', ...]
// 4. Store entity state: entityStates[oid] = MANAGED
// Memory: each entity costs ~2KB in UnitOfWork overhead
// (entity + original data snapshot + state tracking)
// 5,000 entities = ~10MB of UnitOfWork bookkeeping
// 50,000 entities = ~100MB — potential memory_limit hit
// The snapshot is a full copy of every column value at load time.
// When you call flush(), Doctrine diffs current vs snapshot
// to detect changes. For read-only queries, this entire mechanism
// is wasted — you will never call flush().
// Doctrine 2.x workaround: mark queries read-only
$query->setHint(Query::HINT_READ_ONLY, true);
// Skips the original data snapshot. Saves ~40% of UnitOfWork overhead.
// Entities are still instantiated and identity-mapped. Every entity hydrated through HYDRATE_OBJECT gets registered in Doctrine's UnitOfWork. This involves storing the entity reference in an identity map (keyed by class name and primary key), storing a complete snapshot of the entity's column values at load time (for dirty-checking on flush), and tracking the entity's managed state.
The memory cost is approximately 2KB per entity in UnitOfWork overhead. For 5,000 entities, that is 10MB of bookkeeping. For 50,000 entities — not unusual in batch processing — it is 100MB, which can trigger PHP's memory limit.
<?php
// Memory impact of hydration modes (5,000 rows, 15 columns, 2 joins)
// HYDRATE_OBJECT:
// Entity objects: ~8.5 MB (5,000 entities × ~1.7KB each)
// UnitOfWork snapshots: ~10.0 MB (full column value copies)
// Identity map overhead: ~1.2 MB (hash maps, state arrays)
// Association proxies: ~2.8 MB (proxy instances + metadata)
// Total PHP memory: ~22.5 MB
// Peak during hydration: ~31.0 MB (temporary arrays, intermediate state)
// HYDRATE_ARRAY:
// Nested arrays: ~6.2 MB (same data, array overhead)
// No UnitOfWork: 0 MB
// No identity map: 0 MB
// Total PHP memory: ~6.2 MB
// Peak during hydration: ~9.8 MB
// HYDRATE_SCALAR:
// Flat arrays: ~3.8 MB (no nesting overhead)
// Total PHP memory: ~3.8 MB
// Peak during hydration: ~5.1 MB
// DBAL fetchAllAssociative:
// Flat arrays: ~3.4 MB (no DBAL Type object overhead)
// Total PHP memory: ~3.4 MB
// Peak during hydration: ~4.2 MB
// The peak-during-hydration numbers matter for memory_limit.
// HYDRATE_OBJECT's peak is 7.4x the DBAL baseline.
// At 50,000 rows, HYDRATE_OBJECT peaks at ~310 MB.
// PHP's default memory_limit is 128 MB. The memory profile reveals a 6.6x difference between HYDRATE_OBJECT (22.5MB) and DBAL direct (3.4MB) for the same 5,000 rows. More concerning is the peak memory during hydration: HYDRATE_OBJECT peaks at 31MB due to intermediate state during the hydration process. At 50,000 rows, that peak exceeds 310MB — well above PHP's default memory_limit of 128MB.
The Query::HINT_READ_ONLY hint skips the snapshot storage, cutting UnitOfWork overhead by roughly 40%. But entities are still instantiated, still identity-mapped, and still tracked. It is a band-aid, not a solution.
If your query exists to populate an API response, a Twig template, or a report export, the UnitOfWork is doing work that benefits nothing. You will never call flush(). You will never modify these entities. The identity map, the snapshots, the state tracking — all of it is wasted computation for the sake of a default you never chose.
I should offer the counterpoint: the UnitOfWork's identity map does provide one genuine benefit on read paths. If the same entity appears in multiple associations — the same Customer referenced by multiple Orders — the identity map ensures you get the same PHP object instance both times. This preserves referential identity: $order1->getCustomer() === $order2->getCustomer() when both orders belong to the same customer. If your application relies on this identity (comparing entities with === rather than by ID), switching away from HYDRATE_OBJECT breaks that assumption. In practice, most read paths do not rely on referential identity. But if yours does, now you know why.
How to measure hydration cost in your application
Before optimizing anything, you must know what you are optimizing. Hydration cost is invisible to most monitoring tools, which means you need to look in the right places.
<?php
// How to measure hydration cost in your own application
// Method 1: Symfony Profiler (Web Debug Toolbar)
// The Doctrine panel shows query time but NOT hydration time.
// You need Blackfire or Xdebug to see hydration.
// Method 2: Manual timing around getResult()
$queryStart = microtime(true);
$query = $entityManager->createQuery($dql);
// Time the SQL execution separately
$query->setHint(
Query::HINT_INCLUDE_META_COLUMNS,
true
);
$sqlStart = microtime(true);
$stmt = $query->execute(); // This runs the SQL
$sqlTime = microtime(true) - $sqlStart;
$hydrateStart = microtime(true);
$result = $query->getResult(); // This hydrates
$hydrateTime = microtime(true) - $hydrateStart;
$totalTime = microtime(true) - $queryStart;
// Log it:
// SQL: 18ms | Hydration: 1,822ms | Total: 1,840ms
// The ratio tells you everything.
// Method 3: Blackfire profile with Doctrine annotations
// blackfire run php bin/console app:your-command
// Look for: ObjectHydrator::hydrateRowData in the call graph.
// Sort by exclusive time. That's your hydration cost. The Symfony Web Debug Toolbar's Doctrine panel is a useful tool, but it has a critical blind spot: it shows SQL execution time, not hydration time. A query that takes 18ms in PostgreSQL and 1,840ms total will appear as an 18ms query in the Doctrine panel. You will see it and think "my queries are fast." They are. The slowness is elsewhere.
Blackfire is the gold standard for profiling Doctrine hydration. Run blackfire run php bin/console your:command (or profile a web request) and look for ObjectHydrator::hydrateRowData in the call graph. Sort by exclusive time. The hydration cost will be immediately obvious — it typically appears as the single largest time consumer in Doctrine-heavy applications.
If Blackfire is not available, the manual timing approach works. Wrap your getResult() call with microtime(true) and compare to the SQL execution time reported by the Doctrine profiler. The difference is hydration. For production monitoring, consider adding this measurement to your critical endpoints and logging it. A Prometheus gauge for "hydration_seconds" by endpoint is remarkably illuminating.
I would suggest starting with your three highest-traffic endpoints. Measure the total response time, the SQL time (from Doctrine's profiler), and compute the difference. If more than 50% of your endpoint time is the gap between SQL and total, hydration is your bottleneck.
Six strategies for faster reads in Doctrine
Ranked from least to most invasive. Apply them in order until the performance meets your requirements.
Strategy 1: Switch to HYDRATE_ARRAY for read-only endpoints
The simplest change: add Query::HYDRATE_ARRAY to your getResult() calls. You keep DQL, you keep the nested association structure, you lose entity instances. For Symfony controllers that return JSON via serializers, this often requires zero changes to downstream code — the Symfony Serializer normalizes arrays the same way it normalizes entities.
Expected improvement: 2 to 3 times faster. Not transformative, but a five-second code change.
Where it falls short: if your API uses serialization groups defined via attributes on the entity class (#[Groups(['order:read'])]), HYDRATE_ARRAY returns plain arrays that the Serializer cannot apply groups to. You will need either a custom normalizer or explicit field selection in your DQL. API Platform users face this friction more than vanilla Symfony controllers.
Strategy 2: Use DTO projection with the NEW syntax
<?php
// Strategy: Use partial objects for read-only display
// (Deprecated in Doctrine 2.x — removed in 3.x, use DTOs instead)
// Modern approach: DTO projection via NEW syntax (Doctrine 2.10+)
$orders = $entityManager
->createQuery(
'SELECT NEW App\DTO\OrderSummary(
o.id,
o.status,
o.total,
c.name
)
FROM App\Entity\Order o
JOIN o.customer c
WHERE o.status = :status'
)
->setParameter('status', 'pending')
->getResult();
// OrderSummary is a plain PHP class — not an entity.
// Doctrine calls new OrderSummary($id, $status, $total, $name)
// for each row. No UnitOfWork. No identity map. No proxy.
//
// Still runs DBAL type conversion on constructor arguments.
// Still slower than DBAL direct query, but much faster than
// HYDRATE_OBJECT and keeps your code in DQL-land.
//
// HYDRATE_OBJECT: 1,840ms (500 orders, 3 joins)
// DTO projection: 210ms
// HYDRATE_ARRAY: 620ms
// HYDRATE_SCALAR: 85ms
// DBAL fetchAll: 38ms Doctrine 2.10 introduced the NEW keyword in DQL, which lets you project query results directly into plain PHP objects. Doctrine calls the constructor with the selected columns, bypassing entity instantiation, the UnitOfWork, and the identity map entirely.
Expected improvement: 8 to 10 times faster than HYDRATE_OBJECT. DBAL type conversion still runs on the constructor arguments, so it is not as fast as raw DBAL, but it keeps your queries in DQL.
The DTO approach has a meaningful architectural benefit beyond performance: it makes your read model explicit. An OrderSummary DTO class declares exactly which fields the consumer needs. When the Order entity gains new columns, the DTO does not change. When the DTO's needs change, the entity does not change. The decoupling is valuable regardless of the performance implications.
Strategy 3: Register a custom hydrator for hot paths
<?php
// Strategy: Register a custom hydrator for your hottest code paths
use Doctrine\ORM\Internal\Hydration\AbstractHydrator;
class FlatOrderHydrator extends AbstractHydrator
{
protected function hydrateAllData(): array
{
$result = [];
while ($row = $this->statement()->fetchAssociative()) {
// Skip all Doctrine overhead — just return the PDO row.
// Apply only the type conversions you actually need.
$result[] = [
'id' => (int) $row['id'],
'status' => $row['status'],
'total' => $row['total'], // keep as string for money
'customer_name' => $row['customer_name'],
'created_at' => new \DateTimeImmutable($row['created_at']),
];
}
return $result;
}
}
// Register it:
$config = $entityManager->getConfiguration();
$config->addCustomHydrationMode('flat_order', FlatOrderHydrator::class);
// Use it:
$orders = $entityManager
->createQuery(
'SELECT o.id, o.status, o.total, c.name AS customer_name,
o.createdAt AS created_at
FROM App\Entity\Order o
JOIN o.customer c
WHERE o.status = :status'
)
->setParameter('status', 'pending')
->getResult('flat_order');
// Full control over type conversion. No UnitOfWork.
// No identity map. No proxy resolution.
// Keeps your code in Doctrine's query execution pipeline. Doctrine allows you to register custom hydration modes. A custom hydrator extends AbstractHydrator and implements hydrateAllData(), giving you direct access to the PDO result set. You perform exactly the type conversions you need and skip everything else.
This is more work than switching hydration modes, but it gives you the best of both worlds: you stay within Doctrine's query execution pipeline (DQL, parameter binding, prepared statements) while controlling exactly what happens to the result data. For your three or four highest-traffic endpoints, the investment pays for itself.
Expected improvement: 15 to 40 times faster than HYDRATE_OBJECT, depending on how much type conversion you actually need.
Strategy 4: Drop to DBAL for your hottest read paths
<?php
// Strategy: bypass Doctrine ORM entirely for hot read paths.
// Option A: DBAL Connection (Doctrine's database layer, no ORM)
$conn = $entityManager->getConnection();
$rows = $conn->fetchAllAssociative(
'SELECT o.id, o.status, o.total, c.name AS customer_name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = :status
LIMIT :limit',
['status' => 'pending', 'limit' => 5000],
['status' => \PDO::PARAM_STR, 'limit' => \PDO::PARAM_INT]
);
// Returns flat associative arrays. No entity hydration.
// No UnitOfWork. No identity map. No type conversion.
// Total: ~35ms (Postgres execution + PDO fetch + array build)
// Option B: Native SQL with ResultSetMapping (partial hydration)
use Doctrine\ORM\Query\ResultSetMapping;
$rsm = new ResultSetMapping();
$rsm->addEntityResult('App\Entity\Order', 'o');
$rsm->addFieldResult('o', 'id', 'id');
$rsm->addFieldResult('o', 'status', 'status');
$rsm->addFieldResult('o', 'total', 'total');
$rsm->addScalarResult('customer_name', 'customerName');
$orders = $entityManager
->createNativeQuery(
'SELECT o.id, o.status, o.total, c.name AS customer_name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = :status',
$rsm
)
->setParameter('status', 'pending')
->getResult();
// Hydrates Order entities but with manual column mapping.
// Faster than DQL fetch-joins for wide result sets,
// but still pays the entity instantiation and UnitOfWork tax. For the three or four endpoints that handle 60% of your traffic — the dashboard, the list view, the search results — using $entityManager->getConnection()->fetchAllAssociative() eliminates the entire ORM layer. You write SQL directly. You get flat associative arrays. No type conversion, no hydration, no overhead.
Expected improvement: 20 to 90 times faster. The trade-off is that you lose DQL's abstraction, Doctrine's schema awareness, and automatic parameter type binding. For a handful of critical endpoints, this is reasonable. For your entire application, it defeats the purpose of having an ORM. See our analysis of when raw SQL actually wins.
Strategy 5: Use toIterable() for large result sets
<?php
// For large result sets: toIterable() + periodic clear()
// BAD: Loading 50,000 entities into memory at once
$allOrders = $entityManager
->createQuery('SELECT o FROM App\Entity\Order o')
->getResult();
// Peak memory: ~150MB (50K entities × ~3KB each)
// Hydration time: ~8,200ms
// BETTER: Iterable with HYDRATE_ARRAY
$orders = $entityManager
->createQuery('SELECT o FROM App\Entity\Order o')
->toIterable([], Query::HYDRATE_ARRAY);
$batch = [];
$count = 0;
foreach ($orders as $order) {
$batch[] = $order;
$count++;
if ($count % 500 === 0) {
processBatch($batch);
$batch = [];
// No need to clear EntityManager — no entities were created
}
}
processBatch($batch); // remainder
// BEST for write paths: Iterable with clear()
$orders = $entityManager
->createQuery('SELECT o FROM App\Entity\Order o')
->toIterable();
$count = 0;
foreach ($orders as $order) {
$order->setStatus('archived');
$count++;
if ($count % 200 === 0) {
$entityManager->flush();
$entityManager->clear();
// Releases UnitOfWork memory. Essential for batch writes.
}
}
$entityManager->flush(); // final batch For batch processing, report generation, and data exports, toIterable() streams results row by row instead of loading everything into memory at once. Combined with HYDRATE_ARRAY, this eliminates both the memory cost and the per-entity UnitOfWork overhead.
For write paths that must use entities (you need to modify and flush), toIterable() with periodic $entityManager->clear() prevents the UnitOfWork from accumulating unbounded memory. Without periodic clearing, a batch processing 50,000 entities will peak at 310MB of UnitOfWork bookkeeping alone.
Expected improvement: memory usage drops from O(n) to O(batch_size). Processing time remains similar per row, but you avoid the memory limit failures that kill long-running processes.
Strategy 6: Pre-materialize joins at the database level
Instead of fetch-joining three tables and hydrating the Cartesian product, create a PostgreSQL materialized view that pre-joins the data into a flat table. Then Doctrine hydrates from one table with zero joins — no collection assembly, no proxy resolution, no Cartesian explosion.
This is where Gold Lapel enters the picture, and I will address it honestly in the next section.
Result caching does not fix hydration
<?php
// Symfony + Doctrine: the result cache pattern for expensive reads
use Symfony\Component\Cache\Adapter\RedisAdapter;
// In services.yaml or cache configuration:
// framework:
// cache:
// pools:
// doctrine.result_cache:
// adapter: cache.adapter.redis
$orders = $entityManager
->createQuery(
'SELECT o, c FROM App\Entity\Order o
JOIN o.customer c
WHERE o.status = :status'
)
->setParameter('status', 'pending')
->enableResultCache(300, 'pending_orders_list')
// TTL cache key
->getResult();
// First call: full hydration cost (1,840ms for large sets).
// Subsequent calls within 300s: deserialize from cache.
//
// The trap: Doctrine's result cache stores serialized entities.
// unserialize() still has to reconstruct objects.
// For 500 orders with nested associations:
// Cache HIT: ~180ms (unserialize + UnitOfWork registration)
// Cache MISS: ~1,840ms (full hydration)
//
// You are caching the SQL result, not avoiding hydration.
// The real fix is to not hydrate in the first place. A common response to slow Doctrine queries is to add result caching. Doctrine's result cache stores the serialized SQL result set in Redis, Memcached, or the filesystem. On cache hit, it skips the SQL query entirely.
What it does not skip is hydration. On cache hit, Doctrine unserialize()s the stored result rows and then runs the full hydration pipeline — entity instantiation, UnitOfWork registration, association resolution, collection assembly. A cache hit for 500 orders with nested associations still takes roughly 180ms, down from 1,840ms. Better, certainly. But 180ms of pure PHP object construction that could be 38ms with DBAL.
There is a further subtlety that catches teams. Doctrine's result cache serializes the raw result set — the array of database rows before hydration. This means the cached payload includes every duplicated row from the Cartesian product of your fetch-joins. A query that returns 2,000 rows (500 parents × 4 children) caches all 2,000 rows. On cache hit, Doctrine still performs the full deduplication and collection assembly. You have eliminated the network round trip to PostgreSQL and the SQL execution time, but the PHP-side work — which was 98.8% of the total cost — remains.
If you are going to cache, cache the final output (the serialized JSON response, the rendered HTML fragment), not the intermediate Doctrine result set. Application-level caching via Symfony's HTTP cache or a Redis-backed response cache eliminates both the SQL and the hydration. A cached JSON response is a string lookup and a return. Zero hydration. Zero type conversion. Zero UnitOfWork.
Doctrine ORM 3.x: what changes and what does not
If you are planning a migration to Doctrine ORM 3.x — or starting a new project on it — you may wonder whether the hydration situation improves.
<?php
// Doctrine ORM 3.x changes that affect hydration (2024+)
// 1. Partial objects are REMOVED. No more ->partial().
// Use DTO projection (NEW syntax) or HYDRATE_SCALAR instead.
// 2. HYDRATE_SIMPLEOBJECT is REMOVED.
// Was already deprecated. Use DTO projection.
// 3. UnitOfWork is stricter about detached entities.
// clear() + re-query patterns need attention.
// 4. TypedFieldMapper introduced for cleaner type handling.
// Slightly reduces per-row conversion overhead on some types.
// 5. Read-only entities via #[Entity(readOnly: true)] attribute
// Skips UnitOfWork snapshot entirely for marked entities.
// Better than Query::HINT_READ_ONLY — applies at the class level.
// The fundamental hydration architecture is unchanged in ORM 3.x.
// ObjectHydrator still does the same O(n×m) work.
// The recommendations in this article apply to both 2.x and 3.x. The honest answer: incrementally. Partial objects are removed (use DTO projection instead). HYDRATE_SIMPLEOBJECT is gone. The TypedFieldMapper reduces some overhead in type resolution. And the #[Entity(readOnly: true)] attribute is a cleaner way to skip UnitOfWork snapshots than the query hint.
But the fundamental architecture of ObjectHydrator is unchanged. The per-row iteration, the entity instantiation, the collection assembly, the identity map — all of it works the same way in ORM 3.x. The O(n×m) characteristic is structural, not incidental. It would require a fundamental redesign of how Doctrine maps relational result sets to object graphs to change it.
I mention this not to discourage upgrading — Doctrine 3.x has genuine improvements in other areas — but to set expectations. If hydration is your bottleneck on Doctrine 2.x, upgrading to 3.x will not resolve it. The strategies in this article apply equally to both versions.
What Gold Lapel does about this
Honesty first. Gold Lapel is a PostgreSQL proxy. It sits between your PHP application and your database, observing query patterns in real time. It cannot reach into your PHP process and change your hydration mode. It cannot make HYDRATE_OBJECT faster. The 1,822 milliseconds Doctrine spends constructing entity objects happen in your PHP runtime, after the data has left PostgreSQL.
What Gold Lapel can do is reduce the SQL-side cost and, more importantly, reshape the data so that Doctrine's hydration has less work to do.
When Gold Lapel detects a repeated multi-join query pattern — the kind that produces Cartesian explosions with collection fetch-joins — it can auto-create a materialized view that pre-joins the data into a flat, denormalized structure. Your DQL query that was joining orders, customers, items, and products across four tables now reads from a single materialized view. The result set has no duplicated parent rows. There is no Cartesian product to deduplicate. Doctrine's ObjectHydrator processes fewer rows with simpler structure.
The effect on hydration cost depends on the shape of the original query. For a query that produced a 4x row multiplication from collection joins, eliminating the multiplication reduces HYDRATE_OBJECT time by roughly 60 to 70%. Combined with switching to HYDRATE_ARRAY or DTO projection on the application side, you can go from 1,840ms to under 50ms without abandoning Doctrine's query abstractions entirely.
I should be forthcoming about the limitation: Gold Lapel optimizes the shape of data that arrives at your application. It cannot optimize what your application does with that data after it arrives. If you use HYDRATE_OBJECT on a result set that Gold Lapel has already flattened, you still pay the entity instantiation, UnitOfWork, and type conversion costs on the rows that remain. The best results come from Gold Lapel reducing the row count combined with your application using a lighter hydration mode.
Two layers. Two fixes. Neither alone is sufficient for the worst cases.
An honest assessment of the trade-offs
I have spent this article making a case against HYDRATE_OBJECT on read paths. It would be a disservice to leave you without the counterarguments.
Doctrine entities carry domain behavior. If your entity classes contain business logic — validation methods, computed properties, domain event dispatching — then plain arrays or DTOs cannot substitute for them. A $order->canBeCancelled() method encapsulates business rules that would otherwise scatter across your codebase. For code paths that need this behavior, entities are the correct choice regardless of hydration cost.
The UnitOfWork enables transparent persistence. Doctrine's change tracking is genuinely powerful for write paths. Modify an entity, call flush(), and Doctrine generates the minimal set of SQL statements needed. Without the UnitOfWork, you write update queries manually. For applications with complex write logic, the UnitOfWork earns its cost.
Development speed matters. $order->getCustomer()->getName() is more ergonomic than $row['customer_name']. Autocompletion works. Static analysis tools understand entity types. Refactoring tools can follow associations. For teams that move fast, the developer experience of entities has real value — and the hydration overhead at small scale (under 100 rows) is genuinely negligible.
Premature optimization applies here too. If your endpoint returns 20 orders and takes 40ms total, spending three hours rewriting it with DTO projection to save 25ms is a poor trade. Optimize the endpoints where hydration is the bottleneck — large result sets, high traffic, collection fetch-joins — and leave the rest as they are.
The position I am taking is not "never use HYDRATE_OBJECT." It is "use HYDRATE_OBJECT deliberately, not by default." Know what it costs. Know when that cost is justified. And know how to avoid it when it is not.
A practical decision framework for Doctrine hydration
Not every getResult() call needs optimization. The single-row, single-join case costs an extra 1.5ms. That is rarely worth changing. Here is when to act:
- Fewer than 100 rows, 0-1 joins — use
HYDRATE_OBJECT. The overhead is under 20ms. The ergonomics of having real entities are worth it. - 100-1,000 rows, 1-2 joins, read-only — use
HYDRATE_ARRAY. Five seconds to change, 2-3x faster, no downstream impact if you are serializing to JSON. - More than 1,000 rows or collection fetch-joins — use DTO projection or
HYDRATE_SCALAR. The hydration tax at this scale is hundreds of milliseconds. It will be the dominant cost in your endpoint. - Dashboard / list / search endpoints (high traffic) — use DBAL direct queries or a custom hydrator. These three to five endpoints handle most of your read traffic. They deserve hand-tuned data access.
- Write paths (create, update, delete) — use
HYDRATE_OBJECT. This is its purpose. The UnitOfWork, the identity map, the change tracking — they exist for writes. Pay the cost when the cost buys something. - Batch processing (imports, reports, exports) — use
HYDRATE_SCALARor DBAL withtoIterable(), and call$entityManager->clear()periodically if using entities. The UnitOfWork will otherwise accumulate entities until memory runs out.
The Doctrine batch processing documentation recommends iterating with toIterable() and clearing the EntityManager every 100 to 200 entities. This advice is correct but incomplete: if you do not need entities at all, do not hydrate them in the first place.
If you remember nothing else from this article: HYDRATE_OBJECT is for when you need entities. Most of your read paths need data. Choose the hydration mode that matches the need, and the performance will follow.
The household, properly staffed
A well-run household does not assign the same level of ceremony to every task. When a guest arrives for a formal dinner, the full service is warranted: silver polished, table set, courses served in order, every detail attended to. When the guest asks for a glass of water, one does not set the table.
HYDRATE_OBJECT is the formal dinner service. It is thorough, correct, and complete. It prepares entities for every possible use: reading, modification, persistence, association traversal, change detection. For the code paths that need all of this, it is the right choice. The ceremony is earned.
For the code paths that need a glass of water — data to display, data to serialize, data to export — the full service is not attentive. It is wasteful. And in a household that serves thousands of guests per minute, the difference between setting the table and pouring a glass of water is the difference between 1,840 milliseconds and 38.
PostgreSQL executed your query in 18 milliseconds. It has been waiting patiently ever since. Perhaps it is time we stopped keeping it waiting.
Frequently asked questions
Terms referenced in this article
The ORM overhead benchmarks above raise a question that extends well beyond Doctrine. I have taken up the matter at length in a cross-ORM PostgreSQL performance benchmark — Doctrine, Eloquent, ActiveRecord, SQLAlchemy, and Prisma, measured on identical queries, with the hydration and query generation costs separated so you can see precisely where each framework spends its time.