Django's QuerySet is "lazy" and "chainable" and everyone repeats those words, so let's build a working one in about 50 lines and watch the magic disappear.
You've written Book.objects.filter(published=True).order_by("-created_at")[:10] a thousand times. You know it's "lazy." You know it's "chainable." You may have read that "nothing hits the database until you iterate."
But if someone asked you to explain how that actually works (how chaining .filter() five times still produces one query, how the thing knows to wait), could you?
Most of us couldn't, and that's fine, because you don't need to in order to use it. But there's a special kind of confidence that comes from understanding a tool from the inside. The fastest way to get there isn't to read the Django source (it's thousands of lines of edge cases). It's to build a tiny version yourself.
So that's what we're going to do: a working, chainable, lazy QuerySet in about 50 lines of plain Python. No Django, no database, just the ideas. By the end, the "magic" will just be code you wrote.
We'll build it in four small steps, each solving the problem the last one revealed.
Step 1: The Naive Version (and Why It's Wrong)
Let's start with the most obvious way to write a "filter my data" object, so we can feel exactly what's missing. Imagine our "database" is just a list of dicts:
BOOKS = [
{"title": "Dune", "published": True, "year": 1965},
{"title": "Drafty", "published": False, "year": 2020},
{"title": "Foundation", "published": True, "year": 1951},
]
class NaiveQuery:
def __init__(self, data):
self.data = data
def filter(self, **conditions):
# immediately loop and keep matching rows
results = [
row for row in self.data
if all(row.get(k) == v for k, v in conditions.items())
]
return results
NaiveQuery(BOOKS).filter(published=True)
# → [{'title': 'Dune', ...}, {'title': 'Foundation', ...}]
It works! But look at what it forces.
-
It can't chain. Because
filter()immediately returns a plain list,NaiveQuery(BOOKS).filter(published=True).filter(year=1965)blows up, because a list has no.filter(). -
It's eager. It does the full scan the instant you call
filter, even if you only wanted to know "are there any?" or "how many?". If this were a real database, every.filter()would be a separate expensive query.
Those two problems, can't chain and runs immediately, are exactly the two things Django's design solves. Let's solve them too.
Step 2: Make It Chainable (Return a New Object, Don't Execute)
Here's the key insight that unlocks chaining, and it's the same one Django uses:
filter()shouldn't return results. It should return a new query object that remembers the filter and hasn't run anything yet.
So instead of storing the data and filtering it, our query object stores the list of conditions to eventually apply:
class Query:
def __init__(self, data, filters=None):
self.data = data
self.filters = filters or [] # a list of conditions, not results
def filter(self, **conditions):
# DON'T run anything. Return a NEW Query with this filter added on.
return Query(self.data, self.filters + [conditions])
Look at what changed. filter() now returns Query(...) (another Query object) with the new condition appended to the list. It touches no data. And because it returns a Query, that returned object also has a .filter() method, so you can call it again:
q = Query(BOOKS).filter(published=True).filter(year=1965)
# q is a Query holding filters=[{'published': True}, {'year': 1965}]
# ...and still NOTHING has been computed.
This is exactly Django's real behavior, and the real source looks almost identical. Every .filter() calls _clone() to make a new QuerySet, adds the condition to it, and returns it.
Django calls this being immutable through cloning: your original query is never modified; each method hands back a fresh copy with one more instruction. That's why you can safely build a base queryset and branch off it in different directions without them interfering.
We can chain now. But we still haven't solved "when does it actually run?"
Step 3: Make It Lazy (Run Only When Something Asks for Results)
Here's the heart of it. Our Query is now a recipe: a stored list of instructions that hasn't been executed. We want it to execute only when someone actually tries to read the results: loops over it, lists it, counts it.
In Python, "someone loops over this object" has a specific hook: the __iter__ method. That's our trigger.
class Query:
def __init__(self, data, filters=None):
self.data = data
self.filters = filters or []
self._cache = None # results live here AFTER we run
def filter(self, **conditions):
return Query(self.data, self.filters + [conditions])
def _execute(self):
# apply every stored filter, in order - this is the "expensive" part
results = self.data
for condition in self.filters:
results = [
row for row in results
if all(row.get(k) == v for k, v in condition.items())
]
return results
def __iter__(self):
# THE TRIGGER: only now do we actually run the query
if self._cache is None: # ...and only once
self._cache = self._execute()
return iter(self._cache)
Now watch the behavior fall into place:
q = Query(BOOKS).filter(published=True) # nothing runs - just a recipe
print("built the query, DB untouched") # prints; still nothing ran
for book in q: # NOW __iter__ fires → _execute() runs
print(book["title"]) # Dune, Foundation
Building the query ran nothing. The work happened only when we looped. That is lazy evaluation, and it's the entire reason chaining five .filter() calls doesn't mean five database trips. None of them execute; they just stack instructions onto the recipe, and the one execution happens at the end when you consume it.
And notice self._cache. The first time you iterate, we run _execute() and store the results. The second time you iterate the same object, self._cache is already filled, so we skip execution and reuse it.
This is Django's result cache, the exact reason that iterating the same queryset variable twice only queries once. We just built it, in three lines.
Django's real __iter__ is remarkably close to ours. It calls _fetch_all(), which checks "is _result_cache None? If so, run the query and fill it," then hands back the cached list. Same idea, same shape.
Step 4: Add the Terminal Methods (count, exists, first)
The last piece: the convenience methods that consume the query. Now that iteration is our trigger, these are almost trivial to write. They just use the results:
# inside class Query:
def count(self):
return len(list(self)) # list(self) triggers __iter__
def exists(self):
return bool(list(self))
def first(self):
results = list(self)
return results[0] if results else None
Each of these forces evaluation by consuming the query, then answers its narrow question.
And here's a subtlety worth pausing on that mirrors real Django. In our simple version, list(self) fills the cache, so calling .count() and then looping is efficient. In real Django, .count() and .exists() are actually smarter: they send SELECT COUNT(*) and SELECT 1 to the database instead of loading every row.
That's why you should prefer
.exists()overif queryset:when you only need a yes/no.
But the shape is the same: terminal methods are the things that break the laziness and make the query finally run.
The Whole Thing, ~50 Lines
Here's our complete mini-QuerySet. Read it top to bottom. There's no magic left in it, because you built every line:
class Query:
def __init__(self, data, filters=None):
self.data = data
self.filters = filters or []
self._cache = None
def filter(self, **conditions):
# chainable + lazy: return a NEW query, run nothing
return Query(self.data, self.filters + [conditions])
def _execute(self):
results = self.data
for condition in self.filters:
results = [
row for row in results
if all(row.get(k) == v for k, v in condition.items())
]
return results
def __iter__(self):
if self._cache is None: # run once...
self._cache = self._execute()
return iter(self._cache) # ...then reuse the cache
def count(self):
return len(list(self))
def exists(self):
return bool(list(self))
def first(self):
results = list(self)
return results[0] if results else None
# our "manager":
class Manager:
def __init__(self, data):
self.data = data
def all(self):
return Query(self.data)
def filter(self, **conditions):
return Query(self.data).filter(**conditions)
books = Manager(BOOKS)
And it behaves like the real thing:
q = books.filter(published=True) # lazy - nothing ran
print("nothing executed yet")
for b in q: # runs once, caches
print(b["title"]) # Dune, Foundation
print(q.count()) # reuses the cache - no re-run
print(books.filter(year=1965).first()["title"]) # Dune
That Manager at the bottom is the last piece of the puzzle. It's why you write Book.objects.filter(...) and not Query(Book).filter(...). In Django, .objects is a Manager, and its job is exactly what ours does: hand you a fresh QuerySet to start chaining from. Every Book.objects.filter(...) starts a brand-new recipe.
What You Can Now See That You Couldn't Before
You just built, in miniature, the real architecture of Django's ORM. And a bunch of things that were "just how it works" now have obvious mechanical explanations:
| Question | Answer |
|---|---|
Why doesn't Book.objects.filter(...) hit the database? |
Like our filter(), it returns a new object holding instructions and runs nothing. |
Why can I chain .filter().exclude().order_by() endlessly with one query? |
Each one just appends to the recipe and returns a new query; execution happens once, at the end. |
| Why does iterating the same queryset twice only query once? | The result cache: our self._cache, Django's _result_cache. |
Why is a fresh Model.objects.filter(...) a separate query from another one? |
Different Query objects, different caches, exactly like calling our Manager.filter() twice. |
Why does .exists() exist when I could just do if qs:? |
A purpose-built terminal method can ask the database a cheaper question than "load everything." |
None of this is memorization anymore. It falls out of the shape:
A query object stores instructions, stays lazy by returning new copies of itself, and only runs (once, then cached) when something consumes it.
That one sentence is the whole ORM, and now you've felt it from the inside.
The real Django QuerySet is, of course, vastly more. It compiles these instructions into actual SQL across different database backends, handles joins and annotations and prefetching, and manages a hundred edge cases we happily ignored. But the soul of it (lazy, chainable, cloned-not-mutated, cached-after-execution) is exactly the fifty lines you just wrote.
The next time you chain a queryset, you won't see magic. You'll see the recipe being built, waiting patiently for you to ask for the meal.
The 30-Second Takeaway
-
Django's QuerySet feels magical because it's lazy (doesn't run until consumed) and chainable (
.filter().exclude()...), but both are simple mechanics you can build yourself. -
Chainable:
filter()returns a new query object holding the accumulated conditions and runs nothing. Django does the same via_clone(). QuerySets are "immutable through cloning," which is why you can branch a base query safely. -
Lazy: execution is triggered by consumption, Python's
__iter__hook. Building the query runs nothing; looping/listing/counting is what finally executes it. That's why five chained.filter()s = one query, not five. -
Result cache: the first execution stores its results (
self._cachein ours,_result_cachein Django), so iterating the same queryset again reuses them instead of re-querying. -
Terminal methods (
count,exists,first) are the things that consume the query and break the laziness; real Django's.count()/.exists()send cheaper SQL than loading every row. -
.objectsis a Manager. Its only job is to hand you a fresh QuerySet to start chaining from. - Build the ~50-line version once and the real ORM's behavior stops being magic. It's just "store instructions, clone to stay lazy, execute once when consumed."
Your Turn
The best way to make this stick: paste the 50-line version into a file, add an order_by method yourself (hint: it also just appends to the recipe), and watch it slot right in. Understanding beats memorizing, and building beats reading.
Got your order_by working? Share your version in the comments 👇
If this was useful, follow me here for more stories from the backend trenches, and subscribe to The Backend Tech Lead on Substack for weekly blueprints.




Top comments (0)