Home Design Patterns Lazy Load

Lazy Load

Modern Practice

Defer the cost of getting data until something actually asks for it.

Purpose

A Lazy Load is an object that does not contain all the data you will need, but knows how to get the rest. The reason to want one is that loading an object graph is not a single decision: a run has samples, the samples have annotations, the annotations have authors, and following every reference from one root would drag half the database into memory to answer a question about a job number.

Martin Fowler names four flavors in Patterns of Enterprise Application Architecture, and they differ in where the check lives. Lazy initialization puts it in the getter: test a field, load if it is unset, return it. A virtual proxy puts it in a stand-in object with the same interface as the real one, so the caller holds something that looks like a list and is not one yet. A value holder is honest about itself: the field's type is Holder, and the owner knows it is holding one. A ghost is the real object, partially populated, that fills in the rest of itself the first time an unloaded field is touched.

Design

All four share one flag and one loader. The flag is the part people get wrong. Testing value == null is not the same as testing whether the load has happened, because null is a legitimate result: a run with no samples reloads on every single access, forever, and the symptom is a query counter that will not stop climbing. You need a separate boolean.

UML class diagram: a Run holding a Holder, which calls a SampleMapper that queries a datastore on first access.
The load path exists on the diagram and not in the caller's code. getSamples() reads like a field access and can reach the datastore, which is the pattern's entire benefit and its entire problem.

The choice between flavors is really a choice about honesty. A value holder is visible in the field's type, so anyone reading the class knows where the I/O is. A virtual proxy or a ghost is invisible by design, which is what makes them comfortable to use and what makes them dangerous to reason about.

Example

Autobot Robotics's reporting side lists production runs. A run has a small header (job number, arm serial, operator, start, end, result) and a sample series: force and joint position captured at a kilohertz for as long as the run took. A long run is tens of megabytes of samples.

The run list page loaded two hundred runs to show a table of headers, and the mapper faithfully loaded every sample series with them. The report server ran out of memory on the second week a customer had it, and the fix was not subtle: wrap the samples in a holder. The list page touches nothing but the header, so the query never runs, and the page went from unusable to instant.

The pattern then produced its own bug, on schedule. The end-of-shift export loops over the same two hundred runs and does touch samples on each one, and because the loop looks like ordinary in-memory code nobody saw that it was issuing two hundred and one queries where one would have done. It took a slow night shift and a query log to find it. The fix was an explicit eager path for that one report, which is the honest answer: laziness is a default, not a policy, and the caller that knows better has to be able to say so.

class Run
{
    private long id;
    private Holder<Sample[]> samples;

    public Run(SampleMapper m, long runId)
    {
        id = runId;
        samples = new Holder<Sample[]>(() => m.load(runId));
    }

    // Reads like a field. Can open a socket, take a lock and
    // block. Nothing in the signature says so.
    public Sample[] getSamples() { return samples.get(); }
}

class Holder<T>
{
    private bool loaded = false;      // NOT a null check on value
    private T value;
    private Func<T> loader;

    public Holder(Func<T> f) { loader = f; }

    public T get()
    {
        if (!loaded)
        {
            value = loader();
            loaded = true;
        }
        return value;
    }
}

class SampleMapper
{
    public Sample[] load(long runId)
    {
        // one query, run at most once per Run
    }
}
Two lines are the pattern: if (!loaded) and loaded = true. Using value == null as the test instead is the classic bug, because an empty result then reloads on every access for the life of the object.

Seen in the Wild

Hibernate loads collections lazily by default and throws LazyInitializationException when you touch one after the session has closed, which is probably the most frequently asked question in the framework's history. Entity Framework Core does the same thing through generated proxies, and requires navigation properties to be virtual so it can override them. Django's QuerySets are lazy and do not hit the database until they are iterated, and Django ships select_related and prefetch_related precisely because laziness produces N+1 queries. .NET promoted the pattern into the standard library as System.Lazy<T>, with explicit thread-safety modes because getting that part right by hand is hard. The oldest example is not in application code at all: demand paging is Lazy Load in a memory management unit, and copy-on-write after fork() is the same idea again.

Comparisons

  • Proxy is not merely related, it is one of the four flavors. A virtual proxy is a Proxy whose reason for existing is to delay construction of its subject.
  • Singleton is where most programmers meet lazy initialization for the first time, in the form of the check-and-create inside getInstance(), along with all the double-checked locking arguments that come with it.
  • Repository is usually where the loader lives. The repository knows how to fetch, the holder knows when.
  • Aggregate is the better tool for the same problem. If you can decide what belongs together and load an aggregate whole, you need far less laziness inside it.
  • Flyweight also puts a lookup in front of object creation, but to share instances rather than to postpone work.

Why It Exists

My take

Lazy Load exists because the shape of an object graph in memory and the shape of a query you can afford are different things, and object-relational mapping pretends they are the same. Once run.samples is a plain reference, the model has claimed that samples are part of a run, and something has to decide when that claim gets paid for. Without laziness you either load too much, which is the memory problem, or you make every caller assemble what it needs, which means the domain model knows about the database and you have given up the thing you were buying.

My honest view is that it is a performance technique wearing a modeling technique's clothes. What it really does is make property access do I/O. Once getSamples() can open a connection, wait on a lock, block for two hundred milliseconds or throw, every caller's latency budget and error handling is wrong, and nothing in the signature warns them. In control software that is disqualifying, and not as a matter of taste: a field read whose worst case is unbounded cannot appear inside a loop with a deadline. The reason it is tolerated on the office side is that the office side has no deadlines, only averages.

It survives because the alternative asks callers to know their access pattern in advance, and callers genuinely do not. But notice that every mature framework eventually grows a way to override it: select_related, Include(), fetch joins, GraphQL selection sets. That is the real lesson. The interesting question was never "lazy or eager", it was "who gets to decide", and the answer is the caller, per call site, because only the caller knows whether it is showing one row or two hundred.

Criticisms

The N+1 query problem is the direct consequence of this pattern and it is probably the single most common performance bug in database-backed applications. Loop over N parents, touch a lazy child on each, and you have issued N+1 queries. What makes it so persistent is that it is invisible in the source: the loop is plain code operating on plain objects, the laziness is in a field type or in a generated proxy, and the only way to see the problem is to look at a query log under realistic data. It never reproduces on a developer's machine with three rows in the table.

The second one is laziness meeting a closed session, which is the same bug in Hibernate, Entity Framework, SQLAlchemy and every ORM that has ever shipped. You load an object inside a transaction, close the session, hand the object to a view or a serializer, and it touches an unloaded field. Now you get an exception at the outer edge of the system, far away from the code that decided what to load, and every standard remedy trades it for something else: open-session-in-view keeps a database connection alive for the whole render, eager-loading everything undoes the pattern, and mapping to DTOs means writing and maintaining a second set of types. There is no clean answer, which is why the question keeps getting asked.

Beyond those, the invisibility bites in places you do not expect. A serializer that walks properties will trigger every lazy load in the graph and can pull the whole database through one JSON response. So will a debugger's watch window, so hovering over a variable changes the program's behavior. A logger calling ToString() does it too. And the naive check-then-load has a race condition: two threads both see the flag unset, both load, and if the loader has a side effect such as opening a connection you now have two. Add that a proxy is not the object it stands for, so getClass(), instanceof and reference equality all give answers that surprise people, and the total cost of "it just works" is considerably higher than it looks on the day you adopt it.