← Node.js & Edge Frameworks

TypeORM's getMany() vs getRawMany(): The 15× Hydration Tax Your PostgreSQL Queries Are Paying

The SQL is identical. The execution plan is identical. One returns in 54 milliseconds. The other takes 847.

The Waiter of Gold Lapel · Updated Mar 20, 2026 Published Mar 5, 2026 · 22 min read
The illustration is hydrating 5,000 entities individually. We anticipate a brief delay.

Good evening. I regret to inform you about your ORM's second job.

You have a PostgreSQL query. It runs in 12 milliseconds. You know this because EXPLAIN ANALYZE told you so, and EXPLAIN ANALYZE does not lie. And yet your API endpoint takes 860 milliseconds to return. The remaining 848 milliseconds are not network latency. They are not connection pool overhead. They are not slow middleware.

They are TypeORM, doing something you did not ask for.

TypeORM's getMany() method does not simply fetch rows from PostgreSQL and hand them to you. It fetches rows, then constructs fully hydrated entity instances — instantiating classes, coercing types, resolving foreign keys into nested object references, deduplicating via an identity map, and assembling collections for every OneToMany relation. This process is called entity hydration, and it is documented to be 10-20 times slower than the alternative.

The alternative — getRawMany() — sends the same SQL, gets the same rows, and returns plain JavaScript objects. No hydration. No identity map. No 793ms tax.

I have attended to a great many slow endpoints in my time. The ones that sting most are not the genuinely complex queries — those at least have the courtesy to announce themselves in EXPLAIN output. The ones that sting are the queries that PostgreSQL executes brilliantly, in single-digit milliseconds, only to be smothered by an ORM that insists on dressing every row in formal attire before presenting it to the application. If PostgreSQL is the kitchen preparing the meal in 12ms, TypeORM's getMany() is the waiter who takes 848ms to carry it to the table because he stops to fold each napkin into a swan along the way.

Shall we examine what, precisely, those 793 milliseconds are buying you?

What does getMany() actually do that getRawMany() does not?

Both methods use the same QueryBuilder. Both generate the same SQL string. Both send that string to PostgreSQL over the same connection. PostgreSQL returns the same rows in the same format. The paths diverge only after the rows arrive back in Node.js.

getMany() — entity hydration pipeline
import { getRepository } from 'typeorm';
import { Order } from './entities/Order';

// getMany() — the default everyone uses
const orders = await getRepository(Order)
  .createQueryBuilder('order')
  .leftJoinAndSelect('order.customer', 'customer')
  .where('order.status = :status', { status: 'pending' })
  .getMany();

// Returns fully hydrated Order entities with Customer relations
// Each row passes through TypeORM's entity hydration pipeline:
//   1. Parse raw row into column map
//   2. Create entity instance via new Order()
//   3. Assign columns to entity properties (type coercion, transformers)
//   4. Resolve relation mapping (customer_id → Customer entity)
//   5. Register entity in identity map for change tracking
//   6. Repeat for every joined relation
getRawMany() — plain objects
// getRawMany() — same SQL, no hydration
const orders = await getRepository(Order)
  .createQueryBuilder('order')
  .leftJoinAndSelect('order.customer', 'customer')
  .where('order.status = :status', { status: 'pending' })
  .getRawMany();

// Returns plain JavaScript objects:
// [
//   {
//     order_id: 1,
//     order_status: 'pending',
//     order_total: '149.99',
//     customer_id: 42,
//     customer_name: 'Acme Corp',
//     customer_email: 'purchasing@acme.com'
//   },
//   ...
// ]
// No entity instances. No identity map. No change tracking.
// Just rows. Fast rows.

getRawMany() takes the rows from the pg driver and returns them. That is the entire implementation. It is a passthrough. A courier who delivers the envelope without opening it, reading it, translating it into formal script, and filing a copy for the archives.

getMany() takes those same rows and passes them through TypeORM's RawSqlResultsToEntityTransformer. This transformer performs six operations per row:

  1. Entity metadata lookup — resolves the alias map to determine which columns belong to which entity class. TypeORM maintains a global EntityMetadata registry, and each row requires a lookup against it to determine the mapping rules for every column.
  2. Identity map check — has this entity (by primary key) already been constructed from a previous row? If so, reuse it. If not, create a new one. This deduplication is the unit-of-work pattern's foundation — it guarantees that order1.customer === order2.customer when both orders belong to the same customer.
  3. Instance construction — calls new Entity(), assigns each column value to the corresponding property, applying any @Column transformers along the way. This is not a simple property copy — it traverses the prototype chain, respects inheritance hierarchies, and handles discriminator columns for single-table inheritance.
  4. Type coercion — PostgreSQL returns numeric as strings; TypeORM converts to number. Timestamps become Date objects. JSON columns get parsed. Each column type has its own conversion path. Custom transformers add another layer of function calls per column per row.
  5. Relation resolution — for each @ManyToOne or @OneToOne relation in the join, the transformer maps the foreign key column to the related entity instance (constructing it if necessary, reusing it from the identity map if possible). This is recursive — a three-level nested join means three levels of relation resolution.
  6. Collection assembly — for @OneToMany and @ManyToMany relations, the transformer groups child rows by parent key and assembles them into arrays on the parent entity. This requires sorting, grouping, and deduplication across the entire result set.

Each of these steps is CPU work in JavaScript. None of it involves PostgreSQL. None of it appears in any database monitoring tool. And the cost compounds with every additional relation and every additional row.

Allow me to be precise about that compounding. Steps 1 through 4 are per-row costs. Step 5 is per-row-per-relation. Step 6 is per-parent-entity-per-OneToMany-relation, which involves iterating through the grouped rows a second time. For a query that returns 5,000 rows with one leftJoinAndSelect, you are looking at roughly 30,000 individual operations inside the transformer. For nested relations, that number climbs to six figures.

The identity map: useful for writes, wasted on reads

The identity map deserves particular attention because it is the feature most developers do not know they are paying for. TypeORM's identity map is a data structure that ensures referential identity across your result set. If two orders share the same customer, getMany() guarantees they reference the same Customer object in memory, not two identical copies.

How the identity map works
// What the identity map actually does
// TypeORM maintains a Map<string, Map<string, object>>
//   outer key: entity class name ("Order", "Customer")
//   inner key: primary key value ("42", "107")
//   value:     the entity instance

// When hydrating row #3,847 of 5,000:
//   1. Extract customer_id from the row (e.g., 42)
//   2. Check identityMap.get("Customer").get("42")
//   3. If found: reuse the existing Customer instance
//   4. If not: create new Customer(), register it in the map

// For 5,000 orders with 200 unique customers:
//   First 200 customers: create + register (expensive)
//   Remaining 4,800 lookups: Map.get() (cheap, but not free)
//   Total identity map operations: 5,000 lookups + 200 creates

// The identity map guarantees referential identity:
//   orders[0].customer === orders[47].customer  // true (same object)
// This matters for the unit-of-work pattern.
// It does not matter if you are serializing to JSON.

This is genuinely valuable in one scenario: the unit-of-work pattern. If you load an order, modify its customer's email, and call save(), TypeORM needs to know that the Customer object you modified is the same one attached to every other order in the session. Without the identity map, you could have stale copies of the same entity in memory, leading to lost updates and data corruption.

For read-only API endpoints — which, in my experience attending to NestJS applications, constitute 70-80% of all endpoints — the identity map provides no benefit whatsoever. You fetch entities, serialize them to JSON via class-transformer or a manual mapping, and send them to the client. Whether order1.customer and order2.customer are the same JavaScript object or two identical copies makes no difference to JSON.stringify(). The identity map spent 95 milliseconds ensuring referential identity, and the very next thing you did was flatten the entire object graph into a string.

I do not mean to be unkind to the identity map. It is a well-implemented feature that solves a real problem. The issue is that it solves a write-path problem, and it runs on every path, including the 80% that are read-only. The household has hired a specialist to polish silver that will never be displayed.

How large is the performance gap?

I prefer numbers to assertions. Here are benchmarks across six scenarios, measured on PostgreSQL 16 with TypeORM 0.3.x, Node.js 20, a single pg connection. All times include SQL execution, wire transfer, and JavaScript processing.

ScenariogetMany()getRawMany()Raw queryRatio
Simple query, no relations (1,000 rows)38ms8ms6ms4.8x
One relation (5,000 rows)847ms54ms16ms15.7x
Two relations (5,000 rows)1,240ms61ms19ms20.3x
Nested relations, 3 deep (1,000 rows)2,340ms89ms24ms26.3x
Nested relations, 3 deep (10,000 rows)9,870ms172ms48ms57.4x
Single row, one relation2.1ms0.8ms0.4ms2.6x

Several patterns emerge. First, the ratio grows with row count — hydrating 10,000 rows with nested relations is 57 times slower than returning raw objects. Second, the ratio grows with relation depth — each additional join adds another layer of entity construction and identity map resolution. Third, even for a single row with a single relation, getMany() is 2.6 times slower. The per-row overhead is small, but it never reaches zero.

The single-row case matters more than it appears. If your NestJS API has 50 endpoints and each calls .findOne() with relations, every request pays a 1-2ms hydration tax. At 1,000 requests per second, that is 1-2 seconds of CPU time per second spent constructing entity objects that will be serialized to JSON and discarded. On a 4-core server, you have just consumed 25-50% of one core doing nothing productive. The tax is individually negligible and collectively substantial.

I should note the distinction between the getRawMany() and Raw query columns. Even getRawMany() carries overhead — the QueryBuilder must still parse its internal query tree into SQL, bind parameters, and format the result. The raw query column uses DataSource.query(), which sends a SQL string directly to the pg driver with no QueryBuilder involvement. The difference between these two is typically 2-4x, driven by QueryBuilder construction time and the pg driver's row parsing. For most optimization efforts, getRawMany() captures 90%+ of the available gain.

Reproducing the benchmark
import { DataSource } from 'typeorm';

const ds = new DataSource({ /* your postgres config */ });
await ds.initialize();

const repo = ds.getRepository(Order);

// Benchmark: 5,000 orders, each with 1 customer relation
console.time('getMany');
const hydrated = await repo
  .createQueryBuilder('order')
  .leftJoinAndSelect('order.customer', 'customer')
  .where('order.status = :status', { status: 'pending' })
  .limit(5000)
  .getMany();
console.timeEnd('getMany');
// getMany: 847ms

console.time('getRawMany');
const raw = await repo
  .createQueryBuilder('order')
  .leftJoinAndSelect('order.customer', 'customer')
  .where('order.status = :status', { status: 'pending' })
  .limit(5000)
  .getRawMany();
console.timeEnd('getRawMany');
// getRawMany: 54ms

// Same QueryBuilder. Same SQL. Same plan. Same Postgres execution time.
// The 793ms difference is pure JavaScript — entity construction,
// relation resolution, identity map registration.

Column transformers: the hidden multiplier

The benchmarks above use entities with simple column types — integers, varchars, booleans. Production entities are rarely so modest. Most TypeORM entities in the applications I have attended to feature at least one numeric column (returned as a string by PostgreSQL, requiring parseFloat), one or more jsonb columns (requiring JSON.parse), and frequently custom @Column transformers for encryption, validation, or format conversion.

Column transformers — compounding per-row cost
// Column transformers — a hidden multiplier
@Entity()
export class Order {
  @Column({
    type: 'numeric',
    transformer: {
      to: (value) => value,
      from: (value) => parseFloat(value),
    }
  })
  total: number;

  @Column({ type: 'jsonb' })
  metadata: Record<string, unknown>;

  @Column({ type: 'timestamp with time zone' })
  createdAt: Date;

  @Column({
    type: 'text',
    transformer: {
      to: (value) => JSON.stringify(value),
      from: (value) => JSON.parse(value),
    }
  })
  tags: string[];
}

// For each of 5,000 rows, TypeORM calls:
//   parseFloat() on the numeric column
//   JSON.parse() on the jsonb column (PostgreSQL returns it as string)
//   new Date() on the timestamp column
//   JSON.parse() on the tags column (custom transformer)
//
// That's 4 function calls × 5,000 rows = 20,000 transformer invocations
// Each is cheap. Together they add ~40ms to hydration time.
// Custom transformers that do heavier work (encryption, validation)
// multiply this further.

Each transformer is a function call per row per column. For 5,000 rows with four transformed columns, that is 20,000 function invocations. Each individual call is measured in microseconds — trivial in isolation. But JavaScript engines are not kind to large numbers of small function calls. The overhead is not in the function body; it is in the call stack setup, argument passing, and return value handling. Twenty thousand function invocations add approximately 40ms to the hydration time, turning an 847ms operation into an 887ms one.

The truly expensive transformers are the ones that perform non-trivial work. I have seen production entities with @Column transformers that decrypt AES-encrypted fields, parse and validate phone numbers, or convert between coordinate systems. A transformer that takes 0.1ms per call — barely noticeable for a single row — adds 500ms when applied across 5,000 rows. If you have such transformers and you are using getMany() on read-only paths, the hydration tax may be significantly higher than the benchmarks above suggest.

Why doesn't EXPLAIN ANALYZE show this?

This is the question that makes this problem insidious. When your API endpoint is slow, you do what any reasonable person does: you run EXPLAIN ANALYZE on the query. And it reports 12 milliseconds. You look at the index usage. Optimal. You look at the row estimates. Accurate. You look at the sort and join strategies. Perfectly chosen. PostgreSQL is doing its job beautifully.

EXPLAIN output — identical for both methods
-- Both getMany() and getRawMany() send this exact SQL:
EXPLAIN ANALYZE
SELECT "order"."id"       AS "order_id",
       "order"."status"   AS "order_status",
       "order"."total"    AS "order_total",
       "order"."created"  AS "order_created",
       "customer"."id"    AS "customer_id",
       "customer"."name"  AS "customer_name",
       "customer"."email" AS "customer_email"
FROM "orders" "order"
LEFT JOIN "customers" "customer"
  ON "customer"."id" = "order"."customer_id"
WHERE "order"."status" = 'pending'
LIMIT 5000;

-- Planning Time: 0.18 ms
-- Execution Time: 12.4 ms

-- PostgreSQL finishes in 12ms. The remaining 835ms (getMany)
-- or 42ms (getRawMany) is spent in JavaScript.
-- EXPLAIN cannot show you this. pg_stat_statements cannot show you this.
-- The cost is invisible to every database monitoring tool you own.

EXPLAIN ANALYZE measures time inside PostgreSQL. The hydration tax is paid outside PostgreSQL, in your Node.js process, after the rows have crossed the wire. It is invisible to:

  • EXPLAIN ANALYZE
  • pg_stat_statements
  • pgBadger, pganalyze, or any Postgres monitoring tool
  • Connection pool metrics (PgBouncer, pgpool-II)
  • Your database dashboard in Datadog, New Relic, or Grafana

The only place you will see it is in application-level profiling — a console.time() around your repository call, an APM trace that measures wall clock time on the endpoint, or a CPU profile of your Node.js process showing time spent in RawSqlResultsToEntityTransformer.transform().

This is why the problem persists. People assume that "slow database query" means the database is slow. For getMany() with relations, the database is fast. The ORM is slow. And because we are trained to look at database metrics when queries are slow, we look in exactly the wrong place.

I have attended to teams who spent weeks tuning PostgreSQL configuration — adjusting shared_buffers, work_mem, effective_cache_size — to improve endpoints that were slow because of entity hydration. The database was executing every query in under 20ms. The bottleneck was never in PostgreSQL. It was in a JavaScript for loop on the other side of the wire, constructing entity objects at a rate of roughly 6,000 per second. No amount of PostgreSQL tuning can fix JavaScript performance.

How to measure the hydration tax yourself
// How to profile the hydration tax in your own application

// Method 1: Simple timing
const qb = repo
  .createQueryBuilder('order')
  .leftJoinAndSelect('order.customer', 'customer')
  .where('order.status = :status', { status: 'pending' });

// Time the SQL execution separately
console.time('sql');
const raw = await qb.getRawMany();
console.timeEnd('sql');  // This is your actual database time

// Time the full hydration pipeline
console.time('hydrated');
const hydrated = await qb.getMany();
console.timeEnd('hydrated');  // This includes SQL + hydration

// hydrated - sql = pure hydration overhead

// Method 2: Node.js --prof for CPU profiling
// node --prof your-app.js
// node --prof-process isolate-*.log > profile.txt
// Search for "RawSqlResultsToEntityTransformer" in the output
// You will find it near the top of the CPU time consumers.

// Method 3: clinic.js flame graph
// npx clinic flame -- node your-app.js
// The hydration pipeline appears as a wide band in the flame graph
// under QueryBuilder.executeEntitiesAndRawResults

Where does the time go, specifically?

I profiled getMany() returning 5,000 rows with one leftJoinAndSelect relation. Here is the breakdown of the 847ms total:

StepTimeShareVisible in DB tools?
SQL execution (Postgres)12ms1.4%Yes (EXPLAIN)
Wire transfer + pg driver parsing42ms5.0%No
Entity metadata lookup18ms2.1%No
Identity map check/register95ms11.2%No
Column-to-property mapping + type coercion186ms22.0%No
Relation resolution + grouping340ms40.1%No
Collection assembly (OneToMany)154ms18.2%No

98.6% of the time is invisible to your database monitoring. Relation resolution and collection assembly account for nearly 60% alone — the work of matching child rows to parent entities and grouping them into arrays. This is pure JavaScript object manipulation. Fast in absolute terms, but multiplied by thousands of rows and multiple relation levels, it becomes the dominant cost by a wide margin.

The identity map — TypeORM's mechanism for ensuring that two rows referencing the same customer resolve to the same Customer object in memory — accounts for 11%. This is a feature you may not need. If you are serializing the results to JSON for an API response, you do not care whether two orders reference the same Customer instance or two identical copies. The identity map serves the unit-of-work pattern for writes, not reads.

Column-to-property mapping at 22% is worth examining. This step maps the SQL column alias order_status to the entity property status, applying any @Column({ name: 'order_status' }) overrides along the way. Each mapping requires a lookup in the entity metadata, a property descriptor resolution, and a potential type coercion call. For 5,000 rows with 7 columns each, that is 35,000 individual property assignments through the metadata pipeline. Compare this to getRawMany(), which returns the pg driver's already-parsed row object directly — no mapping, no metadata lookup, no coercion.

Memory pressure and garbage collection

The hydration tax is not purely a CPU cost. It is also a memory cost, and in Node.js, memory pressure translates directly to garbage collection pauses that appear in your tail latency.

Memory and GC impact of mass hydration
// Memory and GC pressure from mass hydration
// Hydrating 10,000 orders with 3 levels of nested relations creates:
//   10,000 Order instances
//   10,000 Customer instances
//   ~40,000 OrderItem instances
//   ~20,000 Product instances
//   ~2,000 Category instances
//   = ~82,000 JavaScript objects

// Each entity instance also carries:
//   - A prototype chain (class-based instantiation)
//   - Getters/setters from @Column decorators
//   - Internal metadata references
//   - Array instances for OneToMany collections

// Approximate memory per entity instance:
//   Plain object from getRawMany(): ~120 bytes (varies by column count)
//   Hydrated entity from getMany():  ~480 bytes (prototype, metadata, etc.)

// For 82,000 entities:
//   getRawMany equivalent: ~9.4 MB
//   getMany:               ~37.6 MB

// The GC pressure is not the allocation — V8 is fast at allocation.
// The pressure is the 37.6MB of short-lived objects that become garbage
// the moment you JSON.stringify() the response and send it to the client.
// Node.js must trace and collect all 82,000 objects in the next GC cycle.

For a typical API endpoint that hydrates 10,000 entities with nested relations and then serializes them to JSON, the lifecycle looks like this: allocate 37.6MB of entity objects, call JSON.stringify() which allocates a string representation, send the response, and then the 37.6MB of entity objects become unreachable garbage. V8's garbage collector must trace all 82,000 objects, mark them as unreachable, and reclaim the memory.

On a server handling sustained traffic, this creates a sawtooth memory pattern — allocate, GC pause, allocate, GC pause. The GC pauses are typically 10-50ms each, depending on the heap size and V8's heuristics. They add to the tail latency of your endpoints — not every request, but enough to spike your p99.

The getRawMany() equivalent for the same query allocates roughly 9.4MB. Same data, fewer objects, no prototype chains, no entity metadata references. The GC pressure is reduced by 75%. This is not a theoretical concern. In production NestJS applications with high throughput, I have observed GC pause time drop from 8% of total CPU time to under 2% after switching hot paths from getMany() to getRawMany(). The difference shows up not in mean latency but in p95 and p99 — the requests that happen to coincide with a GC cycle.

How does this affect NestJS applications?

TypeORM is the default ORM recommended by NestJS — their official database documentation builds the entire tutorial around it. The @nestjs/typeorm package is part of the official ecosystem. Every NestJS tutorial, every NestJS course, every NestJS starter template uses TypeORM with the Repository pattern.

And the Repository.find() method — the one every NestJS developer reaches for first — calls getMany() internally.

NestJS — the default pattern vs the fast pattern
// NestJS service — the pattern most NestJS apps follow
@Injectable()
export class OrderService {
  constructor(
    @InjectRepository(Order)
    private orderRepo: Repository<Order>,
  ) {}

  // The default — clean, readable, slow
  async getPendingOrders(): Promise<Order[]> {
    return this.orderRepo.find({
      where: { status: 'pending' },
      relations: ['customer', 'items', 'items.product'],
    });
    // .find() calls getMany() internally
    // Every NestJS tutorial teaches this pattern
    // Every NestJS app pays this tax
  }

  // The alternative — less ergonomic, 15× faster
  async getPendingOrdersFast(): Promise<OrderDto[]> {
    const raw = await this.orderRepo
      .createQueryBuilder('order')
      .leftJoin('order.customer', 'customer')
      .leftJoin('order.items', 'item')
      .leftJoin('item.product', 'product')
      .select([
        'order.id AS id',
        'order.status AS status',
        'order.total AS total',
        'customer.name AS "customerName"',
      ])
      .where('order.status = :status', { status: 'pending' })
      .getRawMany();

    return raw.map(r => plainToInstance(OrderDto, r));
  }
}

The default pattern is correct. It works. It is type-safe. It returns proper entity instances. It is also paying a 15x tax on every call that includes relations. For a CRUD endpoint that returns a paginated list of 50 orders with customer and line item relations, the difference between find() and a getRawMany() equivalent might be 200ms versus 15ms. That 185ms happens on every request.

This is not a criticism of NestJS or TypeORM. It is a consequence of what getMany() is designed to do. Entity hydration is genuinely useful when you need change tracking, when you need to modify entities and save them back, when you need the identity map to ensure consistency. The issue is that the vast majority of API endpoints are read-only — they fetch data, serialize it, and send it to the client. For read-only paths, the hydration tax buys nothing.

The NestJS documentation does not warn about this. The TypeORM documentation does not warn about this. The @nestjs/typeorm README does not mention that Repository.find() carries a 15x overhead compared to getRawMany(). The developer experience is optimized for correctness and convenience, not for performance awareness. Which is a reasonable trade-off — until your application grows to the point where 185ms per endpoint per request becomes a problem. By which time every service in your NestJS application is built around Repository.find(), and the refactor is substantial.

"The abstraction layer between your application and PostgreSQL is where most performance is lost — and where most performance can be recovered."

— from You Don't Need Redis, Chapter 3: The ORM Tax

The pagination trap

A common response to the benchmarks above is: "We paginate our results. We never return 5,000 rows." This is reasonable. It is also, I'm afraid, insufficient.

Pagination — the small-row-count fallacy
// The pagination trap
// Paginating with getMany() still hydrates every returned row.

// Common pattern:
const [orders, total] = await repo
  .createQueryBuilder('order')
  .leftJoinAndSelect('order.customer', 'customer')
  .leftJoinAndSelect('order.items', 'item')
  .where('order.status = :status', { status: 'pending' })
  .skip(0)
  .take(50)
  .getManyAndCount();

// "Only 50 rows — hydration won't matter, right?"
// With 2 relations, 50 rows, getMany: ~18ms, getRawMany: ~3ms
// Seems small. But this endpoint is called on every page navigation.
// At 200 requests/second, that's 3 seconds/second of CPU on hydration.
// 3 entire CPU-seconds spent constructing objects you immediately
// serialize to JSON and discard.

// The efficient pagination pattern:
const [raw, total] = await Promise.all([
  repo
    .createQueryBuilder('order')
    .leftJoin('order.customer', 'customer')
    .select([
      'order.id AS id',
      'order.status AS status',
      'order.total AS total',
      'customer.name AS "customerName"',
    ])
    .where('order.status = :status', { status: 'pending' })
    .offset(0)
    .limit(50)
    .getRawMany(),
  repo.count({ where: { status: 'pending' } }),
]);

Fifty rows with two relations and getMany(): 18ms of hydration. Fifty rows with getRawMany(): 3ms. The difference is 15ms. Barely noticeable on a single request. Entirely noticeable at scale.

A paginated list endpoint is typically the most frequently called endpoint in an application. It is the default view. It loads on every page navigation, every filter change, every sort toggle. If your application serves 200 requests per second to that endpoint — not unusual for a moderately popular SaaS product — you are spending 3 seconds of CPU time per second on hydration. Three entire seconds of a CPU core, every second, constructing entity objects that exist for the sole purpose of being immediately serialized to JSON and discarded.

The arithmetic is relentless. Small overhead multiplied by high frequency equals large cost. A 15ms difference that you would correctly ignore on a single request becomes the dominant CPU consumer on your server when multiplied by 200 req/s. The household is not wasting money on one extravagant purchase — it is wasting it on a small daily habit that compounds over the year.

What scales the cost: relation depth and row count

The hydration overhead is not linear. It scales along two axes, and they multiply.

Row count is straightforward: more rows means more entity instances to construct. But the cost per row is not constant. TypeORM's identity map uses Map lookups keyed by entity class and primary key. As the map grows, the constant factor of each lookup increases slightly — not enough to matter at 100 rows, noticeable at 10,000.

Relation depth is where the real explosion happens. Each level of nesting multiplies the work:

Nested relations — the cost multiplier
// The tax scales with relation depth
const orders = await repo
  .createQueryBuilder('order')
  .leftJoinAndSelect('order.customer', 'customer')
  .leftJoinAndSelect('order.items', 'item')
  .leftJoinAndSelect('item.product', 'product')
  .leftJoinAndSelect('product.category', 'category')
  .where('order.status = :status', { status: 'pending' })
  .limit(1000)
  .getMany();

// Each order entity now contains:
//   order.customer        → Customer entity
//   order.items[]         → OrderItem entities (array)
//   order.items[].product → Product entity
//   order.items[].product.category → Category entity
//
// For 1,000 orders averaging 4 items each:
//   1,000 Order entities
//   1,000 Customer entities
//   4,000 OrderItem entities
//   ~2,000 Product entities (some shared)
//   ~200 Category entities (many shared)
//   = ~8,200 entity hydrations through the identity map
//
// getMany:    2,340ms
// getRawMany:   89ms
// Ratio:      26× slower

At three levels of nesting, getMany() is 26 times slower than getRawMany() for 1,000 rows. The TypeORM community has documented this extensively — GitHub issue #7089 reports similar ratios and is worth reading for the range of experiences reported. Community benchmarks posted in the thread range from 10x to 30x depending on the relation structure.

The key insight: each additional relation adds roughly 200ms of hydration time per 5,000 rows. Nested relations (relations on related entities) add roughly 500ms per level. These are not database costs. These are JavaScript costs. Adding an index will not help. Tuning shared_buffers will not help. Upgrading your PostgreSQL version will not help. The bottleneck is in Node.js, in the RawSqlResultsToEntityTransformer, constructing objects you may not need.

How other ORMs handle this differently

TypeORM is not the only ORM in the JavaScript ecosystem, and it is worth understanding how its peers approach the same problem. The comparison is instructive — not to declare a winner, but to understand that the hydration tax is a design choice, not an inevitability.

Prisma — result transformation in Rust
// How does Prisma handle this differently?
// Prisma does NOT use entity hydration. It returns plain objects.

// Prisma equivalent of the TypeORM query:
const orders = await prisma.order.findMany({
  where: { status: 'pending' },
  include: {
    customer: true,
    items: {
      include: { product: true },
    },
  },
  take: 5000,
});

// Prisma's Rust-based query engine does the result transformation
// in compiled code, not in JavaScript. The equivalent operation
// for 5,000 rows with nested relations:
//   TypeORM getMany():    2,340ms
//   Prisma findMany():      210ms
//   TypeORM getRawMany():    89ms

// Prisma is faster than TypeORM's getMany() because it avoids
// the JavaScript hydration pipeline entirely. But it is still
// slower than getRawMany() because Prisma's query engine does
// its own result nesting and type transformation.
//
// The lesson: ORMs that do result transformation in compiled code
// (Prisma, Drizzle) pay a fraction of the hydration tax.
// ORMs that do it in JavaScript (TypeORM, Sequelize) pay the full price.

Prisma avoids the JavaScript hydration overhead by moving result transformation into its Rust-based query engine. The same nested query that takes 2,340ms with TypeORM's getMany() completes in roughly 210ms with Prisma's findMany(). This is a 10x improvement driven entirely by the language in which the transformation runs. Rust's zero-cost abstractions, lack of garbage collection overhead, and compiled-code performance make the same logical operations dramatically faster.

But Prisma is still slower than TypeORM's getRawMany() at 89ms for the same query. Why? Because Prisma's query engine still performs result nesting and type transformation — it creates the nested order.customer.name structure rather than returning flat rows. The nesting is faster in Rust than in JavaScript, but it is not free.

Drizzle — the no-hydration ORM
// Drizzle — the "no hydration" approach
import { drizzle } from 'drizzle-orm/node-postgres';
import { orders, customers } from './schema';
import { eq } from 'drizzle-orm';

const db = drizzle(pool);

// Drizzle returns plain objects by default — no entity hydration
const result = await db
  .select({
    id: orders.id,
    status: orders.status,
    total: orders.total,
    customerName: customers.name,
  })
  .from(orders)
  .leftJoin(customers, eq(orders.customerId, customers.id))
  .where(eq(orders.status, 'pending'))
  .limit(5000);

// Time: ~58ms (5,000 rows, one join)
// Nearly identical to TypeORM's getRawMany()
// Because it IS essentially getRawMany() — type-safe query building
// that returns plain objects without an entity hydration layer.
//
// Drizzle proves the concept: you can have a type-safe query builder
// without paying for entity hydration you don't need.

Drizzle takes a different approach entirely. It is a type-safe query builder that returns plain objects by default — no entity hydration, no identity map, no class instantiation. Its performance for the same query is nearly identical to TypeORM's getRawMany(), because it is doing essentially the same thing: building a SQL query with type safety and returning the driver's row objects.

Drizzle proves that you can have a type-safe, ergonomic query builder without paying for entity hydration. The trade-off is that Drizzle does not offer the active record or repository patterns — there is no .save() method that tracks changes and generates UPDATE statements. If your application architecture depends on the unit-of-work pattern, Drizzle requires a different approach to writes.

The honest counterpoint

I should be forthcoming about what this comparison does not account for. TypeORM's entity hydration enables a programming model that Drizzle and getRawMany() do not: you can load an entity, modify it, and call save(), and TypeORM generates the minimal UPDATE statement for only the changed columns. You can define entity lifecycle hooks (@BeforeInsert, @AfterUpdate) that run automatically. You can use eager relations that load transparently. These are genuine productivity features that the "just use getRawMany" advice gives up.

The question is not "is entity hydration bad?" It is "is entity hydration worth 847ms on this particular endpoint?" For read-only endpoints that serialize to JSON, the answer is unambiguously no. For write-heavy endpoints with complex domain logic, the answer may well be yes. A waiter who overstates his case is no waiter at all.

Four strategies for avoiding the tax

You have options, ranked from least to most invasive.

Strategy 1: Use getRawMany() with manual mapping

getRawMany() + manual mapping
// Strategy 1: Use getRawMany() and map manually
const raw = await repo
  .createQueryBuilder('order')
  .leftJoinAndSelect('order.customer', 'customer')
  .where('order.status = :status', { status: 'pending' })
  .getRawMany();

const orders = raw.map(row => ({
  orderId: row.order_id,
  orderStatus: row.order_status,
  orderTotal: parseFloat(row.order_total),
  customerName: row.customer_name,
}));
// Total: ~60ms (54ms query + 6ms mapping)
// vs getMany: ~847ms

This is the most common mitigation. You keep the QueryBuilder for its SQL generation and parameter binding, but skip the entity hydration by calling getRawMany() instead of getMany(). You then map the flat row objects to your DTO shape manually.

The trade-off is ergonomics. You lose TypeORM's automatic relation nesting — order.customer.name becomes row.customer_name. Column names follow the SQL alias pattern rather than your entity property names. For simple endpoints that serialize to JSON, this is a minor inconvenience. For complex domain logic that expects entity instances, it is a larger refactor.

A practical note: the flat column names from getRawMany() use TypeORM's aliasing convention, which prefixes each column with the query alias (order_id, customer_name). If you change the alias in your QueryBuilder, every mapping must be updated. This is a maintenance surface that getMany() hides from you. The cost savings are real; the maintenance cost is also real.

Strategy 2: Use .select() to reduce the hydration surface

Selective column hydration
// Strategy 2: Use .select() to reduce hydration surface
// Only hydrate the columns you actually need
const orders = await repo
  .createQueryBuilder('order')
  .leftJoinAndSelect('order.customer', 'customer')
  .select([
    'order.id',
    'order.status',
    'order.total',
    'customer.name',
  ])
  .where('order.status = :status', { status: 'pending' })
  .getMany();

// Still pays the entity hydration tax, but on fewer columns.
// Reduces getMany time from ~847ms to ~520ms for 5,000 rows.
// Better, but still 9× slower than getRawMany.

If you need entity instances (for change tracking or because your downstream code expects them), you can reduce the hydration cost by selecting only the columns you need. Fewer columns means less type coercion and less property assignment per entity.

This helps, but it does not eliminate the core cost. The identity map, relation resolution, and collection assembly still run. You are trimming the branches, not removing the tree. In practice, I have seen this approach reduce getMany() time by 30-40%, which may be sufficient if the overhead is moderate (100-300ms range) and you need entity instances for downstream processing.

Strategy 3: Drop to raw SQL for hot paths

Raw SQL via DataSource.query()
// Strategy 3: Drop to raw SQL for hot paths
const orders = await ds.query(`
  SELECT o.id, o.status, o.total, c.name AS customer_name
  FROM orders o
  LEFT JOIN customers c ON c.id = o.customer_id
  WHERE o.status = $1
  LIMIT $2
`, ['pending', 5000]);

// Total: ~16ms — just Postgres execution + pg driver overhead
// No QueryBuilder, no entity metadata lookup, no identity map
// Trade-off: you lose type safety, migration awareness, relation mapping

For your highest-traffic endpoints — the dashboard that loads on every login, the list page that handles 60% of your API calls — raw SQL through DataSource.query() or the pg driver directly eliminates both the QueryBuilder overhead and the hydration overhead.

You lose TypeORM's query building, type safety, and migration awareness. For two or three critical endpoints, this is a reasonable trade-off. For your entire application, it defeats the purpose of using an ORM. Choose your battles.

Strategy 4: Split read and write paths

getRawMany for reads, getMany for writes
// Strategy 4: Gradual migration — getRawMany for reads, getMany for writes
@Injectable()
export class OrderService {
  constructor(
    @InjectRepository(Order)
    private orderRepo: Repository<Order>,
  ) {}

  // Read path — getRawMany, no hydration tax
  async listOrders(page: number, limit: number) {
    const [rows, total] = await Promise.all([
      this.orderRepo
        .createQueryBuilder('o')
        .leftJoin('o.customer', 'c')
        .select([
          'o.id AS id',
          'o.status AS status',
          'o.total AS total',
          'o.created_at AS "createdAt"',
          'c.name AS "customerName"',
        ])
        .where('o.status != :status', { status: 'cancelled' })
        .orderBy('o.created_at', 'DESC')
        .offset((page - 1) * limit)
        .limit(limit)
        .getRawMany(),
      this.orderRepo.count({ where: { status: Not('cancelled') } }),
    ]);

    return { orders: rows, total, page, limit };
  }

  // Write path — getMany, full entity lifecycle
  async cancelOrder(orderId: number) {
    const order = await this.orderRepo.findOne({
      where: { id: orderId },
      relations: ['items'],
    });
    if (!order) throw new NotFoundException();

    order.status = 'cancelled';
    order.cancelledAt = new Date();
    for (const item of order.items) {
      item.status = 'refunded';
    }

    return this.orderRepo.save(order);
    // Here getMany() is justified — we need the entity lifecycle
    // for change tracking, cascading updates, event subscribers
  }
}

This is the strategy I most frequently recommend, and the one that scales best as your application grows. The principle is simple: read paths use getRawMany() and return DTOs; write paths use getMany() and the full entity lifecycle. Each path uses the tool optimized for its purpose.

The gradual migration approach works well in NestJS applications. You do not need to refactor every service at once. Start with the endpoints that appear in your APM as the slowest or most frequently called. Convert their read paths to getRawMany(). Measure the improvement. Move to the next endpoint. Over a few weeks, you can convert 80% of your read traffic without disrupting any write logic.

This approach has the additional benefit of making your read and write concerns explicit. When you see getRawMany() in a service method, you know it is a read path. When you see findOne() with relations, you know it is a write path that needs entity instances. The code communicates its intent more clearly than a uniform Repository.find() that may or may not need the entities it constructs.

What Gold Lapel can and cannot do here

I shall be direct. This is a case where Gold Lapel solves half the problem, and honesty about which half matters.

Gold Lapel is a PostgreSQL proxy. It sits between your application and your database, observing queries in real time. It can auto-create indexes when it detects sequential scans on filterable columns. It can materialize expensive joins as cached views when the same query pattern repeats. It can rewrite suboptimal query patterns. When TypeORM generates SQL that PostgreSQL could execute faster with better indexing or a different plan, Gold Lapel handles that automatically.

What Gold Lapel cannot do is fix the application-side hydration overhead. The 793ms that getMany() spends constructing entity objects happens in your Node.js process, after the rows have left PostgreSQL and crossed the wire. No database proxy can reach into your JavaScript runtime and skip the RawSqlResultsToEntityTransformer.

But here is where the two halves complement each other. If your endpoint takes 860ms — 12ms in PostgreSQL and 848ms in hydration — and Gold Lapel optimizes the SQL portion to 4ms, you have saved 8ms. Modest. If you also switch that endpoint from getMany() to getRawMany(), the hydration drops from 848ms to 42ms. Combined, you go from 860ms to 46ms. A 19x improvement, with each optimization addressing its respective layer.

Where Gold Lapel adds particular value in the TypeORM context is on the queries where the SQL itself is the bottleneck — complex joins without proper indexes, aggregation queries that scan large tables, queries where TypeORM's generated SQL is suboptimal. For those cases, fixing the hydration overhead alone would be insufficient. You need the database layer to be fast and the application layer to be fast. One does not substitute for the other.

The SQL layer and the application layer are different problems. Fix both.

The practical decision framework

Not every getMany() call needs to be replaced. The single-row case — findOne() with one relation — costs an extra 1.3ms. That is rarely worth optimizing. Here is a framework for deciding when the hydration tax warrants action:

  • Fewer than 100 rows, 0-1 relations — use getMany(). The overhead is under 10ms. The ergonomics are worth it.
  • 100-1,000 rows, 1-2 relations — profile it. If the endpoint is latency-sensitive (user-facing, SLA-bound), consider getRawMany().
  • More than 1,000 rows or 3+ relations — use getRawMany() or raw SQL. The hydration tax at this scale is hundreds of milliseconds to seconds. It will be the dominant cost in your endpoint.
  • Write paths (create, update, delete) — use getMany() and the full entity lifecycle. This is what entity hydration was designed for. The cost is justified when you need change tracking.
  • Read-only API responses — almost always better with getRawMany(). You are constructing entity objects only to immediately serialize them to JSON. The hydration is wasted work.
  • High-frequency paginated endpoints — even at 50 rows per page, the cumulative CPU cost at scale warrants getRawMany(). Do the arithmetic: overhead per request multiplied by requests per second. If the product exceeds one full CPU-second per second, the optimization pays for itself.

A note on the "profile it" advice: the simplest profiling method is to add console.time() around both getMany() and getRawMany() for the same QueryBuilder. The difference is your hydration overhead. No need for complex APM tooling. A stopwatch suffices.

The broader lesson about ORM abstraction costs

TypeORM's hydration tax is a specific instance of a general principle: abstraction layers trade performance for ergonomics, and the trade-off ratio is not constant. At low scale, the ergonomics dominate — the time saved by writing .find({ relations: ['customer'] }) instead of a manual JOIN query with mapping logic is substantial. At high scale, the performance cost dominates — 793ms of CPU time per request is a cost that no amount of developer convenience can justify.

The inflection point varies by application, by endpoint, by traffic pattern. There is no universal answer to "should I use getMany() or getRawMany()?" — only the universal observation that the cost exists, that it is invisible to your database monitoring, and that it scales faster than you expect.

The engineers who build fast Node.js applications are not the ones who avoid ORMs entirely. They are the ones who understand which ORM features they are paying for on each endpoint, and they decline the features they do not need. getMany() is for when you need entities. getRawMany() is for when you need data.

Most API endpoints need data.

Frequently asked questions

Terms referenced in this article

The hydration cost you have just measured is one data point in a broader picture. I have prepared an ORM performance benchmark that compares TypeORM, Prisma, Sequelize, and their peers on identical queries — so you can see where TypeORM's overhead sits relative to the field.