Loading content...
When your code accesses a relationship—order.getCustomer() or customer.getOrders()—a fundamental question arises: When should the related data be loaded from the database?
This seemingly simple question has profound implications for performance, resource utilization, and system behavior. Get it wrong, and your application might:
This page dissects the loading strategies available, when to use each, and the pitfalls that trap even experienced engineers.
By the end of this page, you will understand: (1) The mechanics of lazy and eager loading, (2) The N+1 query problem and how to avoid it, (3) Explicit loading as a controlled alternative, (4) When to choose each strategy, (5) ORM-specific implementation patterns, and (6) Advanced techniques like batch loading and lazy loading proxies.
Eager loading fetches related entities at the same time as the primary entity. When you query for an Order with eager loading of its Items, a single database operation retrieves both the Order and all its OrderItems.
How it works:
order.getItems(), data is already in memory—no database callThe benefits are clear:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// Eager loading examples across different ORMs // TypeORM - using 'relations' optionconst order = await orderRepository.findOne({ where: { id: orderId }, relations: ['items', 'items.product', 'customer']});// Single query (or optimized batch queries)// order.items and order.customer are fully loaded // Prisma - using 'include'const order = await prisma.order.findUnique({ where: { id: orderId }, include: { items: { include: { product: true } }, customer: true }});// All related data loaded immediately // Sequelize - using 'include' in query optionsconst order = await Order.findByPk(orderId, { include: [ { model: OrderItem, as: 'items', include: [{ model: Product }] }, { model: Customer, as: 'customer' } ]}); // Entity Framework (C#) - using 'Include'var order = await context.Orders .Include(o => o.Items) .ThenInclude(i => i.Product) .Include(o => o.Customer) .FirstOrDefaultAsync(o => o.Id == orderId); // Hibernate (Java) - using JOIN FETCH in JPQLvar order = entityManager.createQuery( "SELECT o FROM Order o " + "JOIN FETCH o.items i " + "JOIN FETCH i.product " + "JOIN FETCH o.customer " + "WHERE o.id = :id", Order.class) .setParameter("id", orderId) .getSingleResult();The generated SQL:
Eager loading typically generates one of two query patterns:
123456789101112131415161718192021222324252627282930
-- Pattern 1: JOIN-based (single query, possibly large result)SELECT o.id, o.order_date, o.status, c.id as customer_id, c.name as customer_name, c.email, i.id as item_id, i.quantity, i.unit_price, p.id as product_id, p.name as product_nameFROM orders oLEFT JOIN customers c ON o.customer_id = c.idLEFT JOIN order_items i ON o.id = i.order_idLEFT JOIN products p ON i.product_id = p.idWHERE o.id = ?; -- Note: If Order has 10 items, this returns 10 rows-- The ORM de-duplicates to create one Order with 10 Items -- Pattern 2: Batch queries (multiple queries, cleaner results)-- Query 1: Get the orderSELECT * FROM orders WHERE id = ?; -- Query 2: Get items for this orderSELECT * FROM order_items WHERE order_id = ?; -- Query 3: Get products for these itemsSELECT * FROM products WHERE id IN (?, ?, ?, ...); -- Query 4: Get customerSELECT * FROM customers WHERE id = ?; -- Total: 4 queries instead of potentially N+1-- Each query returns clean, non-duplicated rowsWhen eagerly loading multiple collections on the same entity, JOIN-based queries can explode. If Order has 10 Items and 5 ShippingEvents, the result might have 10 × 5 = 50 rows for one Order. ORMs handle this, but the data transfer can be massive. Watch your query results.
Lazy loading defers fetching related entities until they're actually accessed. When you query for an Order, only the Order data is loaded. The Items are fetched only when you call order.getItems().
How it works:
order.getItems(), the proxy triggers a database querygetItems() return the already-loaded dataThe appeal is obvious:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// Lazy loading behavior (conceptual illustration) class Order { private id: string; private customerProxy: LazyProxy<Customer>; private itemsProxy: LazyProxy<OrderItem[]>; // Called by ORM during hydration _setCustomerProxy(proxy: LazyProxy<Customer>): void { this.customerProxy = proxy; } // Access triggers lazy load getCustomer(): Customer { if (!this.customerProxy.isLoaded()) { // This triggers: SELECT * FROM customers WHERE id = ? this.customerProxy.load(); } return this.customerProxy.getValue(); } getItems(): OrderItem[] { if (!this.itemsProxy.isLoaded()) { // This triggers: SELECT * FROM order_items WHERE order_id = ? this.itemsProxy.load(); } return this.itemsProxy.getValue(); }} // Usage feels naturalconst order = await orderRepo.findById(orderId);// Only order data loaded const customer = order.getCustomer(); // NOW customer is loaded (triggers query) const items = order.getItems();// NOW items are loaded (triggers another query) // Problem: In a loop, this is disasterconst orders = await orderRepo.findAll(); // 1 queryfor (const order of orders) { console.log(order.getCustomer().name); // 1 query per order! // For 100 orders = 100 additional queries}// Total: 101 queries when 2 would sufficeHow ORMs implement lazy loading:
Different ORMs use different mechanisms:
Proxy objects: Subclasses or wrapper objects that intercept property access and trigger loading
Virtual/override methods: Entity methods are overridden to check load status before returning
Explicit loading check: Properties are nullable; accessing null triggers a fetch
Promise-based: Properties are Promises that resolve to actual data when awaited
Lazy loading requires an active database session/context. If you load an Order, close the session, then try to access order.getItems(), you'll get a 'session closed' or 'lazy initialization' error. This is the infamous 'LazyInitializationException' in Hibernate or 'disposed context' error in Entity Framework.
The N+1 query problem is the most common and devastating performance issue in ORM-based applications. It occurs when:
This seems innocuous at development scale (10 orders × 1 query = tolerable). At production scale, it's catastrophic (10,000 orders × 1 query = 10,001 queries for what should be 2 queries).
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
// THE N+1 PROBLEM IN ACTION // ❌ PROBLEMATIC: N+1 queries hidden in innocent-looking codeasync function getOrderSummaries(): Promise<OrderSummary[]> { // Query 1: Get all orders const orders = await orderRepo.findAll(); // SELECT * FROM orders const summaries: OrderSummary[] = []; for (const order of orders) { // Each iteration triggers lazy loads: // Query 2, 3, 4, ...: Get customer for each order const customer = order.getCustomer(); // SELECT * FROM customers WHERE id = ? // Query N+1, N+2, ...: Get items for each order const items = order.getItems(); // SELECT * FROM order_items WHERE order_id = ? summaries.push({ orderId: order.id, customerName: customer.name, itemCount: items.length, total: items.reduce((sum, i) => sum + i.subtotal, 0) }); } return summaries;} // For 100 orders:// - 1 query for orders// - 100 queries for customers// - 100 queries for items// Total: 201 queries! // ✅ SOLUTION 1: Eager loadingasync function getOrderSummariesOptimized(): Promise<OrderSummary[]> { // Single query (or small number of batch queries) const orders = await orderRepo.findAll({ relations: ['customer', 'items'] }); return orders.map(order => ({ orderId: order.id, customerName: order.customer.name, itemCount: order.items.length, total: order.items.reduce((sum, i) => sum + i.subtotal, 0) }));}// Total: 1-3 queries regardless of order count // ✅ SOLUTION 2: Batch loadingasync function getOrderSummariesBatch(): Promise<OrderSummary[]> { // Query 1: Get all orders const orders = await orderRepo.findAll(); // Query 2: Batch load all needed customers const customerIds = [...new Set(orders.map(o => o.customerId))]; const customers = await customerRepo.findByIds(customerIds); const customerMap = new Map(customers.map(c => [c.id, c])); // Query 3: Batch load all needed items const orderIds = orders.map(o => o.id); const allItems = await orderItemRepo.findByOrderIds(orderIds); const itemsByOrder = groupBy(allItems, 'orderId'); return orders.map(order => ({ orderId: order.id, customerName: customerMap.get(order.customerId)!.name, itemCount: itemsByOrder.get(order.id)?.length ?? 0, total: (itemsByOrder.get(order.id) ?? []) .reduce((sum, i) => sum + i.subtotal, 0) }));}// Total: 3 queries regardless of order count| Approach | 10 Orders | 100 Orders | 10,000 Orders |
|---|---|---|---|
| Naive lazy loading | 21 queries | 201 queries | 20,001 queries |
| Eager loading (JOIN) | 1 query | 1 query | 1 query |
| Batch loading | 3 queries | 3 queries | 3 queries |
The N+1 problem is insidious because the code looks completely correct. It passes code review, works in tests, and seems fine with small data sets. It only explodes in production with real data volumes. Always analyze SQL logs during development to catch this early.
Explicit loading is a middle ground: relationships are not automatically loaded (like lazy loading), but you explicitly trigger loading when needed (avoiding the invisible database calls problem).
The key differences from lazy loading:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
// Explicit loading pattern class Order { private id: string; private customerId: string; private customer: Customer | null = null; private items: OrderItem[] | null = null; // ID is always available (stored on entity) getCustomerId(): string { return this.customerId; } // Full entity might not be loaded getCustomer(): Customer { if (this.customer === null) { throw new Error("Customer not loaded. Call loadCustomer() first."); } return this.customer; } isCustomerLoaded(): boolean { return this.customer !== null; } // Explicit load method - clear about what it does async loadCustomer(): Promise<void> { this.customer = await this.customerRepository.findById(this.customerId); } getItems(): ReadonlyArray<OrderItem> { if (this.items === null) { throw new Error("Items not loaded. Call loadItems() first."); } return this.items; } async loadItems(): Promise<void> { this.items = await this.orderItemRepository.findByOrderId(this.id); }} // Usage is explicit about database callsconst order = await orderRepo.findById(orderId); // This is clear that database calls happenawait order.loadCustomer();await order.loadItems(); // Now safe to accessconst customer = order.getCustomer();const items = order.getItems(); // Batch loading pattern with explicit loadingasync function processOrders(orders: Order[]): Promise<void> { // Batch load all customers at once const customerIds = orders.map(o => o.getCustomerId()); const customers = await customerRepo.findByIds(customerIds); const customerMap = new Map(customers.map(c => [c.id, c])); // Assign loaded customers to orders for (const order of orders) { order._setCustomer(customerMap.get(order.getCustomerId())!); } // Batch load all items at once const orderIds = orders.map(o => o.id); const allItems = await itemRepo.findByOrderIds(orderIds); const itemsByOrder = groupBy(allItems, 'orderId'); for (const order of orders) { order._setItems(itemsByOrder.get(order.id) ?? []); } // Now process with everything loaded for (const order of orders) { // No database calls happen here processOrder(order, order.getCustomer(), order.getItems()); }}There is no universally correct loading strategy. The right choice depends on your access patterns, data characteristics, and performance requirements.
Key questions to ask:
| Scenario | Recommended Strategy | Why |
|---|---|---|
| Known, consistent access pattern | Eager loading | Minimize round trips; predictable behavior |
| Access pattern varies by caller | Lazy loading (carefully) | Load what's needed; but watch for N+1 |
| Large collections (100+ items) | Explicit + pagination | Never load unbounded data eagerly |
| Processing list of entities | Batch loading | Fixed query count; avoids N+1 |
| Single entity detail view | Eager with specific includes | One query gets everything needed |
| List/table view | Minimal eager + IDs only | Fast list load; lazy on drill-down |
| Report/analytics | Raw SQL or projection | Skip entity materialization |
| API response | Eager load exactly what DTO needs | Match loading to response shape |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
// Strategy selection in practice class OrderRepository { // List view: minimal data, optimized for table display async findAllForListView(pagination: Pagination): Promise<OrderListItem[]> { return this.prisma.order.findMany({ select: { id: true, orderDate: true, status: true, // Include just what the list needs customer: { select: { id: true, name: true } }, _count: { select: { items: true } }, // Count, not full items total: true }, ...pagination }); } // Detail view: full data for single order display async findByIdWithDetails(id: string): Promise<Order> { return this.prisma.order.findUnique({ where: { id }, include: { customer: true, items: { include: { product: true }, orderBy: { createdAt: 'asc' } }, shippingAddress: true, payments: true } }); } // Bulk processing: batch load for efficiency async findAllForProcessing(ids: string[]): Promise<Order[]> { const orders = await this.prisma.order.findMany({ where: { id: { in: ids } } }); // Batch load items for all orders at once const items = await this.prisma.orderItem.findMany({ where: { orderId: { in: ids } }, include: { product: true } }); // Attach items to orders const itemsByOrderId = groupBy(items, 'orderId'); for (const order of orders) { order.items = itemsByOrderId.get(order.id) ?? []; } return orders; } // Reports: raw projection, no entity overhead async getRevenueByCustomer(): Promise<RevenueReport[]> { return this.prisma.$queryRaw` SELECT c.id as customer_id, c.name as customer_name, COUNT(o.id) as order_count, SUM(o.total) as total_revenue FROM customers c LEFT JOIN orders o ON c.id = o.customer_id WHERE o.status = 'COMPLETED' GROUP BY c.id, c.name ORDER BY total_revenue DESC `; }}Create specific repository methods for specific use cases: findForListView(), findWithDetails(), findForProcessing(). Don't try to make one generic find() handle all loading variations—it becomes impossibly complex. Method names document the loading behavior.
Beyond basic eager/lazy/explicit, several advanced techniques address specific scenarios:
1. DataLoader Pattern (Batch + Cache)
Popularized by GraphQL, DataLoader batches and caches relationship loads within a single request:
1234567891011121314151617181920212223242526272829
// DataLoader batches multiple load requests into single query import DataLoader from 'dataloader'; // Create loader (typically per-request in web apps)const customerLoader = new DataLoader<string, Customer>(async (ids) => { // ids might be [id1, id3, id7] - accumulated from multiple resolve calls const customers = await customerRepo.findByIds(ids as string[]); // Must return in same order as input ids const customerMap = new Map(customers.map(c => [c.id, c])); return ids.map(id => customerMap.get(id)!);}); // Usage in resolvers (GraphQL example)const resolvers = { Order: { customer: (order: Order) => { // This just queues the load return customerLoader.load(order.customerId); } }}; // When resolving many orders, DataLoader:// 1. Collects all customerLoader.load(id) calls// 2. Deduplicates IDs// 3. Makes ONE batch query// 4. Returns individual results to each caller2. Select Loading (Projection)
Load only specific fields, not full entities:
123456789101112131415
// Instead of loading full Customer entity just for nameconst order = await orderRepo.findById(orderId, { include: { customer: true } // Loads all customer fields});const name = order.customer.name; // Load only what you needconst order = await orderRepo.findById(orderId, { include: { customer: { select: { id: true, name: true } // Only id and name } }});// customer object has only id and name - smaller memory, faster query3. Load Splitting
Split a large eager load into parallel batch queries:
123456789101112131415161718192021222324252627
// Instead of one giant JOINconst order = await orderRepo.findWithEverything(orderId);// SELECT * FROM orders // JOIN customers ...// JOIN order_items ...// JOIN products ...// JOIN shipping ...// JOIN payments ...// Result: massive row duplication // Split into parallel queriesasync function loadOrderWithDetails(orderId: string): Promise<Order> { const [order, items, shipping, payments] = await Promise.all([ orderRepo.findById(orderId, { include: { customer: true } }), orderItemRepo.findByOrderId(orderId, { include: { product: true } }), shippingRepo.findByOrderId(orderId), paymentRepo.findByOrderId(orderId) ]); // Assemble in memory order.items = items; order.shipping = shipping; order.payments = payments; return order;}// 4 parallel queries, no row duplication, often faster overall4. Cursor-Based Pagination for Large Collections
Never load large collections fully; paginate with cursors:
12345678910111213141516171819202122232425262728293031323334
// NEVER do this for potentially large collectionsconst allItems = await order.getItems(); // Could be millions // Instead, always paginateinterface PaginatedResult<T> { items: T[]; cursor: string | null; hasMore: boolean;} class Order { async getItemsPage(cursor?: string, limit: number = 20): Promise<PaginatedResult<OrderItem>> { const items = await this.orderItemRepo.findByOrderId(this.id, { cursor, take: limit + 1 // Fetch one extra to check hasMore }); const hasMore = items.length > limit; const resultItems = hasMore ? items.slice(0, -1) : items; const nextCursor = hasMore ? resultItems[resultItems.length - 1].id : null; return { items: resultItems, cursor: nextCursor, hasMore }; }} // Usagelet cursor: string | undefined;do { const page = await order.getItemsPage(cursor, 100); for (const item of page.items) { processItem(item); } cursor = page.cursor ?? undefined;} while (cursor);Different ORMs have different defaults and capabilities. Understanding your ORM's approach is critical.
| ORM | Default | Eager Loading | Lazy Loading | Notes |
|---|---|---|---|---|
| Prisma | No loading | include: {...} | Not supported | Explicit about what loads; no surprises |
| TypeORM | No loading | relations: [...] | @ManyToOne({ lazy: true }) | Lazy requires careful session management |
| Sequelize | No loading | include: [...] | Not built-in | Very explicit; batch loading via include |
| Hibernate | Lazy | JOIN FETCH | Default for collections | Watch for N+1; use batch-size hints |
| Entity Framework | No loading | .Include() | .UseLazyLoadingProxies() | Lazy requires proxies; explicit preferred |
| Django ORM | Lazy | select_related(), prefetch_related() | Default | Powerful prefetch for complex scenarios |
12345678910111213141516171819202122232425262728293031323334353637
// TypeORM: Configuring lazy loading with eager fallback@Entity()class Order { @ManyToOne(() => Customer) customer: Customer; // Not lazy - loads when order loads if you use relations @OneToMany(() => OrderItem, item => item.order, { lazy: true }) items: Promise<OrderItem[]>; // Lazy - returns Promise} // Prisma: Explicit includes at query time// No decorator configuration - always specify at queryconst order = await prisma.order.findUnique({ where: { id }, include: { items: true } // Must explicitly request}); // Sequelize: Scopes for common loading patterns@Scopes(() => ({ withCustomer: { include: [{ model: Customer, as: 'customer' }] }, withItems: { include: [{ model: OrderItem, as: 'items', include: [Product] }] }, full: { include: [ { model: Customer, as: 'customer' }, { model: OrderItem, as: 'items', include: [Product] } ] }}))@Tableclass Order extends Model {} // Usage with scopesconst order = await Order.scope('full').findByPk(id);Every ORM generates SQL differently. Enable query logging during development. Understand exactly what queries each loading pattern generates. Don't assume—verify. The 'magic' of ORMs works until it doesn't, and then you need to understand the underlying behavior.
N+1 problems are often invisible in code review. Here's how to detect and fix them systematically.
12345678910111213141516171819202122232425262728293031323334353637383940414243
// Query counting in tests describe('OrderService', () => { it('should load orders efficiently', async () => { // Create test data: 100 orders with items and customers await createTestOrders(100); // Reset and capture query count const queryLogger = new QueryLogger(); // Execute the method under test const summaries = await orderService.getOrderSummaries(); // Assert on query count const queryCount = queryLogger.getCount(); expect(queryCount).toBeLessThanOrEqual(5); // Should be ~3, not 201 // Alternatively: expect specific queries expect(queryLogger.getQueries()).toEqual([ expect.stringContaining('SELECT * FROM orders'), expect.stringContaining('SELECT * FROM customers WHERE id IN'), expect.stringContaining('SELECT * FROM order_items WHERE order_id IN') ]); });}); // Middleware for request-level query countingfunction queryCountMiddleware(req: Request, res: Response, next: NextFunction) { const startCount = getQueryCount(); res.on('finish', () => { const endCount = getQueryCount(); const queryCount = endCount - startCount; if (queryCount > 50) { console.warn(`High query count: ${queryCount} queries for ${req.path}`); } res.setHeader('X-Query-Count', queryCount.toString()); }); next();}What's next:
The final page of this module tackles normalization vs denormalization trade-offs—the strategic decisions about data structure that determine whether your loading strategies are even viable. We'll explore when to optimize for write consistency vs read performance, and how these database design choices affect your code-level data modeling.
You now understand the loading strategy spectrum from eager to lazy to explicit, can identify and fix N+1 problems, know how to choose strategies for different scenarios, and have advanced techniques for complex loading needs. Next, we'll complete the picture with normalization trade-offs.