How-to: Lazy Loading With /db And Python

What is Lazy Loading?

Before we can describe what lazy loading in code means, we first have to look at how it’s different from what programmers are used to. You may not have heard the name for it before, but you’ve certainly used it before. Typical code uses an “eager” (rather than lazy) design pattern. We are all familiar with how this works: you tell the compiler or interpreter to allocate memory for a variable, and it does so, almost immediately. The variable happily sits in memory, able to be referenced until you deallocate it or the program garbage-collects it.

Now suppose that your code fetches this variable’s value from a really slow API service. In this case, an eager program design would be ineffective. The code’s execution halts until the API responds. In contrast, a lazy program design works like this: you write your code to call the API, but when run, the program, in effect, says to you, “yeah, yeah, I’ll get to it – but only if I have to.” When later in your script, you need this time-expensive value for a calculation or search, the lazy-style program will only then fetch the value and initialize the variable.

It seems like we’re only moving the problem from one place in the code to another: you still have to fetch the attribute anyway. This is why a lazy design pattern isn’t always friendly, but there are certain cases where this style of coding is helpful. When it comes to programming, examples are usually better than words. Let’s take a look at how you might implement lazy loading yourself.

Demo: Lazy Loading using Python and the SlashDB Chinook Database

For starters, let’s make ourselves a function to fetch data from the API. For this example, we’ll use SlashDB’s demo Chinook Database. Using the requests module from PyPI, our function will take a relative API URL, fetch that data, and return the JSON response.

import requests

BASE_URL = 'https://demo.slashdb.com/'

def retrieve_data(rel_url):
    url = BASE_URL + rel_url
    response = requests.get(url)

    # Throw an exception on HTTP errors (404, 500, etc).
    response.raise_for_status()

    # Parse the response as JSON and return a Python dict.
    return response.json()

We use a relative URL as the parameter because the API implements relationships in JSON using relative URLs, so this bit of code will save us some string work later.

APIResponse class

The API responses are dicts in our code, so we want our class to model the response to behave like a dictionary, except we’ll override the normal key-value lookup behavior to insert our lazy behavior. Inheriting from core Python core types such as liststr and dict can be messy and behave unexpectedly, because these types are implemented in C, rather than Python. So we need to try something else.

Thankfully the Python standard library comes to the rescue! We can create our API response class to inherit from the Mapping abstract base class from the collections.abc module. As far as the rest of our code can tell, APIResource is the same as dictionary in terms of functionality. If we forget to add any methods, our code would not run – that’s why inheriting from an abstract base class is useful. Another handy side effect of this inheritance is that our APIResponse becomes immutable, which makes sense for an object modeling a read-only API. Here’s our code:

 from collections.abc import Mapping

import requests

# ----- snipped retrieve_data() ------

class APIResponse(Mapping):

    def __init__(self, **kwargs):

        if kwargs.get('response') is not None:
            self.json_response = kwargs.get('response')

        elif kwargs.get('url') is not None:
            self.__href = kwargs.get('url')
            self.json_response = None

        else:
            database = kwargs.get('database')
            table = kwargs.get('table')
            resource_id = kwargs.get('resource_id')

            self.__href = f'/db/{database}/{table}/{table}Id/{resource_id}.json'
            self.json_response = None

    def __getitem__(self, key):

        self.__load()

        val = self.json_response[key]

        if not (isScalar(val) or isinstance(val, APIResponse)):
            # Check if the attribute is a relationship with other API data
            # If val is a dictionary with only 1 key ('__href'),
            #     then more data is available for download
            if '__href' in val:
                val = APIResponse(url=val['__href'])
                self.json_response[key] = val

        return val

    def __load(self):
        if self.json_response is None:
            self.json_response = retrieve_data(self.__href)
        return None

__init__

This definition of __init__ allows our code to construct APIResource objects in three different ways. The first way, the constructor can take a dictionary of data to serve as the json_response, which could be useful in caching implementations because it avoids calling the API at all. The second and simplest method is by passing a URL. And finally, the constructor also takes keyword arguments for the desired database, table, ID of the relevant data, and builds the URL from those. For these last two methods, notice that json_reponse is set to None – we’ll give it a value later. This is the first hint of lazy loading.

__getitem__

By operator overloading __getitem__ we allow our APIResponse to act like a dictionary, but we can insert our lazy loading features here. The code is simple. When a value is requested (using response['Key']), __getitem__ first checks if this response has fetched its data from the API yet by using the __load function. This is the first aspect of lazy loading in our code – why fetch data until you know you need it? After calling the API, __getitem__ checks if the value requested is actually a relationship to another API object, and if it is, the function creates a new APIResponse instance to return. This is the other piece of code that makes our class lazy, all attributes that require a separate API call have laziness built in.

The sharper reader will notice our code calls a function that does not exists yet – isScalar. It’s a simple function; all it does is tell our code if the value requested is an int, float, bool, or str, the scalar types of JSON.

def isScalar(value):
    return isinstance(value, (int, str, float, bool))

Odds and Ends

We can’t quite run this code yet. Before we can create APIResponse objects, our Mapping inheritance means we need to define all of the abstract methods inside that abstract base class. Specifically, we need to add __iter__ and __len__. While we’re at it, let’s make the code a little more developer friendly by adding a few helpful methods. We’ll add some class methods for easier instantiation, along with a few other Python “magic methods”, which only pass their behavior from self.json_response.


class APIResponse(Mapping):

    # ----- snipped __init__, __getitem__ -----

    @classmethod
    def fromJSONResponse(cls, response):
        """Create an APIResponse object from existing data."""
        return cls(response=response)
    
    @classmethod
    def fromURL(cls, url):
        """Create an APIResponse object from a relative URL."""
        return cls(url=url)

    @classmethod
    def fromKeys(cls, database, table, resource_id):
        """Create an APIResponse object from a database, table, and id."""
        return cls(database=database, table=table, resource_id=resource_id)

    def __len__(self):
        self.__load()
        return len(self.json_response)

    def __iter__(self):
        self.__load()
        return iter(self.json_response)

    def __contains__(self, key):
        self.__load()
        return key in self.json_response

    def __repr__(self):
        if self.json_response is None:
            return f'<unfetched APIResponse object>'
        else:
            return '<APIResponse ' + repr(self.json_response) + '>'

How Does Lazy Loading Work?

In order to understand exactly how lazy loading performs, we’ll use a debugger to really illustrate what’s going on and give you a clearer view. First, we create an APIResponse called resp from a URL.

resp = APIResponse.fromURL('/db/Chinook/Album/AlbumId/1.json')

APIResponse before fetching

Just after creation, the APIResponse object hasn’t yet called the API

Now let’s look at the title for this album.

resp['Title']

APIResponse after fetching

After requesting an attribute of resp, our code fetched the API data

Look at all those new keys in json_response! Our code fetched the data from the API, but you’ll see on Artist and Track we have links to other data in the API. This is where lazy loading shines. Let’s try to look at one of those keys.

resp['Artist']

Lazy loading in action!

Now that we’ve requested one of resp’s relationships, it becomes an unfetched APIResponse, and the cycle repeats.

This program is really lazy! You asked for Artist so it gave you an APIResponse and it didn’t even bother to fill out any of its attributes. Laziness creates more laziness.

The Pudding – Testing Out Lazy Loading

To put lazy loading to the test, let’s use Python’s timeit module to compare our APIResponse class to an eager alternative. To make this class for comparison, I copied and pasted the code for APIResponse into another file and removed some of the “lazy” functionality. If we removed all lazy functionality, one API request would attempt to fetch the whole database, piece by piece, through the original requests’s relationships, through those relationships own relationships, through the relationships’ relationships’ relationships and so on. In this case, our eager class fetches its direct attributes, and no more. It will not fetch the attributes of its relationships.

Test #1

For our first test, we’ll have Python create an APIResponse object over and over again, and then access a single attribute. We’ll run the test 10 times and then 100 times. Here are the results, copied and pasted from my terminal.

$ python time_test.py     # test 10 times
Time for lazy:  2.734821861
Time for eager: 38.222376892
Lazy was about 14 times faster!
$ python time_test.py     # test 100 times
Time for lazy:  27.830144855
Time for eager: 383.906078893
Lazy was about 14 times faster!

Test #2

For our other test, we’ll iterate through the first 10 albums (AlbumId’s 1-10), and check how many of those albums have more than 10 tracks. Obviously the exact details of this test aren’t important, what’s important is that we’re iterating through the responses and checking attributes.

$ python time_test.py     # test 1 time
Time for lazy:   4.964331371
Time for eager:  33.088113383
Lazy was about 7 times faster!
$ python time_test.py     # test 10 times
Time for lazy:   50.144948269
Time for eager:  333.422545044
Lazy was about 7 times faster!

Obviously all tests were conducted on the same computer, internet connection, etc. Between tests, I changed the number of tries within the script. If you’re unsure if your code will be faster with lazy loading, try it out and do a time test yourself for your situation. These two samples are not indicative of every case ever.

When Should You Use Lazy Loading?

Lazy loading may appear very attractive, especially to new developers. It’s tempting to start adding it to all of your classes or attributes, just in case you don’t use all of your object’s attributes. The downside is that lazy loading overrides the normal indexing access, so your code becomes confusing quickly, and hard for other developers to read. Avoid the temptation to use lazy loading, unless you know that you need it. You should use lazy loading if you have no way to predict which attributes another developer/end-user will use and either an attribute, such as one with a numerical value, that takes a long time to calculate or an API used by your code has slow response times.

It’s worth noting, if another piece of code will certainly use an attribute that takes a long time to calculate, there is no need to apply lazy loading. It doesn’t matter whether you calculate a value now or later, it will slow down your program the same amount regardless of when the processor calculates it. Similarly, if the API your program uses has fast response times, lazy loading might not improve your code. Run a side by side test to see which is faster. When you’re making a decision, be sure to remember that readability and maintainability matter – a lot.

The very curious reader, can find all of the code I used for this tutorial/example here on my GitHub. Your constructive comments and improvements are welcome!