Skip to content
Course contents

Say What You Want, Not How to Get It

Declarative queries hide the access path — which is exactly why the database can get faster while your code stays still.

Lesson 4 of 59 min read

By the end of this lesson you can

  • Explain what a query planner is free to do that a hand-written loop is not
  • Rewrite an imperative data-access loop as a declarative query and say what you gained
  • Name the cases where declarative genuinely loses

Two ways to ask for the same thing.

Imperative — a recipe. Do this, then this, in this order:

const results = [];
for (const post of allPosts) {
  if (post.status === 'published') {
    for (const tag of post.tags) {
      if (tag === 'databases') { results.push(post); break; }
    }
  }
}
results.sort((a, b) => b.publishedAt - a.publishedAt);

Declarative — a description. Here is what I want; you figure it out:

SELECT * FROM posts
 WHERE status = 'published' AND 'databases' = ANY(tags)
 ORDER BY published_at DESC

Same result. Wildly different properties.

What the loop has quietly promised

The imperative version does not only specify the outcome. It specifies, whether you meant to or not:

  • that every post is examined, in the order they appear
  • that filtering happens before sorting
  • that this runs on one core, in your process, over data already in memory

None of that was part of the requirement. All of it is now a constraint, because a future maintainer cannot tell which lines were the intent and which were incidental.

The idea

Declarative code says what. That leaves the how free — and the freedom is what lets the database change its mind about execution without you changing a line.

What the planner does with that freedom

Given the SQL, the database chooses:

  • Access path. Sequential scan, or an index on status, or a GIN index on tags, or both combined via a bitmap.
  • Order of operations. If a tag index exists and databases is rare, filter on the tag first and check status on the survivors. If almost everything is published, the reverse.
  • Whether to sort at all. If an index already provides published_at order, the sort disappears entirely.
  • Parallelism. Multiple workers scanning partitions, if the table is big enough to be worth it.

And it re-decides every time, using current statistics. Your query from 2021 will use a plan appropriate to the data of 2026. The loop will still be doing exactly what it was told in 2021.

The same argument, outside databases

This is not a database idea. It is the reason CSS works.

.post.featured { border-color: red; }

You did not say when to apply that, or how to recompute it when the DOM changes, or whether to batch the repaint. You described a state. The browser decides how to achieve it, and browsers got dramatically faster at doing so — while that line of CSS stayed the same.

The imperative equivalent — walking the DOM and setting styles by hand — locks in an approach that cannot benefit from any of it. Every declarative interface you have liked works this way, including the framework you use to render.

Where declarative loses

It is not free, and pretending otherwise makes people distrust the whole idea.

You lose direct control. When the planner picks a bad plan — usually from stale or skewed statistics — you cannot simply tell it what to do. You are negotiating through indexes, hints, and query rewrites, which is a strange way to fix something you can see is wrong.

Performance becomes less predictable. The same query can be fast for a year and then fall off a cliff when the data distribution shifts enough to flip a plan. Nothing in your repository changed, which makes it a genuinely confusing class of incident.

Some things are awkward to express. Iterative algorithms, complex state machines, anything genuinely step-by-step. Recursive CTEs exist and are impressive, but there is a point past which you are fighting the model.

Check yourself

A dashboard query has been fast for a year, then becomes 40× slower overnight. No deploy went out. What is the most likely explanation?

What to take away

Declarative interfaces trade control for the ability to improve underneath you. For data access that trade is almost always worth taking — and when it bites, it bites in a specific, recognisable way: the same query, a different plan.

Finished this one?

skip for now