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 ontags, or both combined via a bitmap. - Order of operations. If a tag index exists and
databasesis rare, filter on the tag first and checkstatuson the survivors. If almost everything is published, the reverse. - Whether to sort at all. If an index already provides
published_atorder, 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.