0

Eager loading vs lazy loading — fetching related data now vs on-demand

Beginner5 min read·eng-15-014
sqlinterviewperformance

Concept

Eager loading vs lazy loading — two strategies for loading related data, with opposite timing.

Lazy loading: Load related data ONLY when it's actually accessed. The relation is fetched on demand — when you first touch $user->orders, a query fires. Convenient (transparent), but dangerous for collections (N+1 problem).

Eager loading: Load related data UPFRONT, before you need it. You specify what to load when writing the query. User::with('orders')->get() — orders are loaded in a separate query that fetches all orders for all retrieved users at once.

The key difference:

  • Lazy: Query fires when you access the property. One query per object.
  • Eager: Query fires when you call get(). One query for ALL objects at once.

In Eloquent:

  • Lazy loading: $user->orders — fires SELECT * FROM orders WHERE user_id = ? on first access.
  • Eager loading: User::with('orders') — fires SELECT * FROM orders WHERE user_id IN (1,2,3...) once.
  • Default eager loading: $with = ['orders'] in the model — always eager loads.

Lazy loading (the intentional kind): In non-ORM contexts, "lazy" means deferring expensive computation until needed. PHP generators are lazy — they yield values on demand rather than building the full collection upfront. cursor() in Eloquent is lazy — fetches rows from DB one at a time (streaming).

When to use which:

  • Displaying a list with related data → eager loading.
  • Single model display where relation MAY not be needed → lazy loading.
  • Large datasets → cursor() (lazy streaming from DB).

Code Example

php
<?php
// LAZY LOADING — relation fetched on first access
$user = User::find(1);
// No orders query yet

if ($user->isPremium()) {
    // Only now is the query fired
    $orders = $user->orders; // SELECT * FROM orders WHERE user_id = 1
    echo count($orders);
}
// Good: only queries orders when actually needed

// BAD lazy loading (N+1):
$users = User::all(); // SELECT * FROM users
foreach ($users as $user) {
    echo $user->profile->avatar; // SELECT * FROM profiles WHERE user_id = ? (per user!)
}

// EAGER LOADING — all relations loaded with the parent query
$users = User::with('profile')->get();
// Query 1: SELECT * FROM users
// Query 2: SELECT * FROM profiles WHERE user_id IN (1, 2, 3, ...)

foreach ($users as $user) {
    echo $user->profile->avatar; // already in memory — no query
}

// NESTED eager loading
$orders = Order::with([
    'user',              // load user
    'items',             // load items
    'items.product',     // load product for each item (dot notation for nesting)
    'items.product.category', // even deeper
])->get();
// 4 queries: orders, users, items, products (categories if nested)

// CONDITIONAL eager loading
$users = User::with(['orders' => function ($query) {
    $query->where('status', 'pending')->orderBy('created_at', 'desc');
}])->get();
// Only loads PENDING orders, ordered by date

// DEFAULT eager loading on the model
class User extends \Illuminate\Database\Eloquent\Model
{
    protected $with = ['profile']; // always eager-load profile
}
User::all(); // automatically includes profile in every query

// LAZY — generators / cursor() (streaming lazy loading)
// cursor() fetches one row at a time from DB — memory efficient for large datasets
User::cursor()->each(function (User $user) {
    // $user loaded one at a time — generator under the hood
    ProcessUser::dispatch($user);
});
// vs get() which loads ALL users into memory at once

// PHP generator lazy evaluation (lazy by nature)
function lazyRange(int $start, int $end): \Generator
{
    for ($i = $start; $i <= $end; $i++) {
        yield $i; // only computes next value when asked
    }
}
$million = lazyRange(1, 1_000_000); // no memory: nothing computed yet
echo $million->current(); // 1 — first value computed on demand