Application code has more than one path to a write.
The obvious one runs through your service layer, where you put the check: is this caller allowed, does this value make sense, is this record still editable. But the admin console has its own path to the same tables. So does the background job, the management command, the second service, the shell someone opens at 2 a.m. to fix a stuck record by hand. Every one of those reaches the data, and every one is a place your carefully-written check simply isn’t.
The database is the one thing that sees all of them. Every write, whichever door it came through, lands there. So for a small number of rules, that’s where the rule belongs — at the one point nothing can go around.
The word “rule” is where this idea goes wrong if you’re not careful, so let me draw the line hard. This is not “move your logic into the database.” Pricing, totals, rates, retention windows, what a given role is even allowed to do — that all stays in application code and in ordinary rows, where you can read it and change it. Push a customer-specific rule down into a database policy and the next customer is a migration. What moves down is a different, smaller thing: invariants. The facts that must be true of the data no matter who’s writing. Two of them carry most of the weight — that a row belongs to exactly one tenant and never leaks to another, and that a posted financial record can be reversed but never quietly edited. Those aren’t business logic. They’re promises about the data, and the database is the only place that can keep a promise every path has to honor. The rough test: push down what must never be true; keep up what merely should.
Here’s what that looked like across two features most tutorials would build entirely in Python — and one closing point that turned out to matter more than either.
The column nobody reads, on purpose
Say you’ll eventually need to store a shape on the ground — a boundary, a service area. In Postgres that’s a job for PostGIS, an extension that teaches the database to store and query real geometry. Django has an official front end for it, GeoDjango, so the obvious move is to switch it on and model the shape as a field like any other.
The obvious move hides a layer confusion worth pulling apart. PostGIS lives in the database. GeoDjango leans on two native libraries — GDAL and GEOS — that have to sit on the app host, so Python can hand you geometry objects. They sound like one feature; they’re two, on two different machines, and only the database half is required to store a shape. The capability you want is already present where the data is. Installing it a second time, in another language, on every web server, buys nothing the database didn’t already have.
So there was a decision, and the interesting part is the option in the middle. You could install the host libraries
and model the field the tidy way — rejected, native dependencies everywhere for zero new capability. You could
defer geometry entirely until something needed it — and that’s the rejection worth sitting with. Adding a typed
column later is a five-minute change. Reconstructing geometry that was never captured is impossible. The cost of
an empty column is close to zero; the cost of not having one when the data starts arriving is unbounded and points
only one way. So the third option won: add the real, typed geometry column now, through a raw SQL migration, with
no Django field pointing at it at all — because a migration is just a place to run schema changes, and the ORM’s
model field was never the only way to have a column.
What you’re left with looks wrong until you see the intent: a real geometry column your Django models cannot read, that nothing reads yet, sitting empty. That’s not an oversight to paper over. It’s a reservation — the reason written in a comment right next to it. The honest version of this story isn’t a clever SQL-driven spatial pipeline quietly humming under the app; there isn’t one, and I’m not going to pretend there is. It’s smaller and rarer than that: a schema decision made early on purpose, because this is the one part of the system that is genuinely expensive to add late. The generalizable lesson isn’t about geometry at all. It’s that some decisions are capture-shaped — you can only ever decide them before the data shows up — and for those, the asymmetry between “nearly free now” and “impossible later” is the entire argument. A column that costs nothing while it sits empty isn’t indecision. It’s the one part of the decision that couldn’t be put off.
The rule that doesn’t care which door you used
The second feature is permissions, and it’s where “many paths to a write” gets sharp.
Two invariants have to hold no matter what. A row belongs to exactly one tenant, and a query must never hand one tenant another’s data. And a posted record can be reversed with a new entry, never edited in place. The normal way to enforce both is in the application — check the tenant before returning rows, block the edit in the service. That covers the paths you remembered. The admin changelist is one you didn’t. So is the autocomplete, the raw-id lookup, the one-off query to unstick a record. Listing every door by hand is exactly how one gets left unlocked.
Postgres can move the isolation check off the doors and onto the data itself, with a feature called Row-Level Security. With RLS, the database decides which rows a caller may see, on every query, regardless of path. You write the policy once and the answer to “can this caller see this row” lives in the same place as the row.
That handles the first invariant. The second one — that a settled record can never be edited — is where a lot of “just use RLS” advice quietly overreaches, because RLS can’t do it. Row-level security is good at exactly one kind of question: may this session see or touch this row? That’s a question about who is asking. Immutability is a different kind of question — may this row ever change again, by anyone, from now on? — and that’s not about identity, it’s about history. No policy can express it. For that you reach for the other tool the database keeps in the same drawer: a trigger, which fires on the write itself and can simply refuse to let a posted record be edited.
Which turns the whole idea from a slogan into an actual decision. “Push down what must never be true” is one principle with two instruments, and picking between them is the skill. The test is short: is the rule about who is asking, or about what has already happened? Who’s asking is a policy. What’s happened is a trigger. Get it backwards and you’ll write a policy that can’t hold the line you needed — which is, a little too fittingly, the exact mistake this post is about, and one I had in an earlier draft until someone checked it against the running database.
There’s a single anecdote that makes the case better than any argument, and I heard it from two people independently. Someone deleted an application-layer permission check — the ordinary kind, in the service code — specifically to watch its tests go red. Thirteen of thirteen stayed green. A row-level policy had been doing the refusing the entire time. Two layers of protection, one of them the tested one, and nobody knew which was actually load-bearing until it was pulled out. A passing test told them the code worked; it couldn’t tell them which code.
A couple of design choices are worth borrowing. The permission model isn’t an enum baked into code — roles resolve to sets of capabilities through a table, so a new tenant defines its own without a code change. And the override rule has a deliberate asymmetry: a tenant’s own settings replace the platform defaults, and leaving something out is how you revoke it — silence means no, on purpose — except for a small set of capabilities marked protected, which are additive and can only ever be granted. That exception has a scar behind it: an administrator managed to revoke their own administration and lock themselves out, more than once. Protected means a floor, not a ceiling — the power to run the place can be delegated, but not deleted out from under everyone. The general shape is useful anywhere config overrides defaults: decide, per default, whether it’s even overridable, and make the ones that aren’t additive rather than special-cased in code.
Why it lives in the migrations
All of it installs the same way the tables do — as SQL inside ordinary migrations. The policies didn’t arrive with the tables, to be honest; they were retrofitted a few migrations in, in one dedicated pass, while the schema was still young and — this is the part that matters — before there was any production data to protect or a crowd of writers to coordinate. A policy is a schema object with no ORM model of its own, so it needs a home in the ordered, versioned, replayable history that already exists. Apply your policies with a setup script run by hand instead, and a freshly built database and a deployed one can silently disagree about who’s protected — which defeats the whole point. There are 121 of these policies across two dozen migration files now, each one landing in the same reviewable diff as the schema it guards.
That “retrofitted early” is worth saying plainly, because “born with the tables” is a story nobody can act on — almost everyone reading this already has tables. The precondition that actually made it feasible isn’t start this way. It’s do it before you have production data and many writers, while a policy that comes back empty is a bug you catch in an afternoon instead of an incident you find in a log.
That choice comes with a sharp edge, and it’s the bridge to the real point of all this. Historical migrations must never be edited — they’re immutable history. So when a policy gets corrected, its original, wrong text stays in the file forever, and a later migration carries the fix. Which means you cannot answer “what is the current policy?” by reading the migrations. The source is a record of what you did, in order — not a description of what is. Ask it the wrong question and it will confidently mislead you: a check that grepped migration files once reported a committed security fix as still missing, in messages marked urgent, twice in one day.
What it costs, honestly
None of this is free, and the failure modes are strange enough that they’re the most useful part to write down.
The one to lead with is more specific than the folklore, and the specifics are reassuring: a refused read is silent, a refused write is loud. Ask for rows you’re not allowed to see and you don’t get an error — you get an empty result, indistinguishable from “there’s nothing here.” From the application’s side, a row that doesn’t exist and a row you’re not allowed to see are the identical experience, and the difference is often a session variable someone forgot to set. But try to write a row a policy forbids and Postgres stops you cold, by name: new row violates row-level security policy. So the silent failure is read-side only — which is the comforting half, because the dangerous direction is the one where you see less than you expected, never the one where you wrote something you shouldn’t have. That silent read is still the real debugging tax; it just isn’t the scary one.
Then there’s the trap that should sound familiar to anyone who’s watched a green checkmark lie. The role that runs your migrations may be a superuser — and a superuser bypasses row-level security entirely. So a migration can be tested against a role for which your policies are invisible: it writes a row its own policy would forbid, passes in development, passes CI, and breaks the first time it runs somewhere the role isn’t a superuser. Green, green, and then a broken deploy. It’s worth checking, on any project that does this, whether the role running your migrations can even see the rules you’re writing.
There’s a sharper cousin of that hiding in any helper function you give elevated privileges. A function that runs
with its owner’s rights instead of the caller’s — in Postgres, a SECURITY DEFINER function — does not skip
row-level security because of anything the keyword does. It skips it, or doesn’t, entirely on the strength of who
owns it. And if that owner is a superuser, then every one of those functions is an unbounded exemption from the
rules you so carefully wrote — whether it needs one, whether you meant to grant one, whether you even remember
writing it. The exemption is a property of the owner, not of the function or your intent. So the question to ask of
one of these is never “does this need to see privileged rows?” It’s “who owns it, and what does that role bypass?”
Only the second question has an answer the database will actually enforce.
The same shape haunts the tests. A security test run as a privileged role proves nothing — a “reproduction” of an attack can pass because the actor legitimately held the permission, not because the code refused. Every negative security check needs a positive control: show the same actor is refused without the trick, or the refusal isn’t attributable to anything.
And the one I’d warn hardest about: once the database is the authority, you will feel the pull to mirror the same rule up in the application — for a dropdown, an API response, a check before the write. The mirror drifts. In the worst case the two don’t disagree by a little; they disagree in opposite directions — one treating an omission as “revoke,” the other as “grant” — so testing either proves nothing about the other, and you’ve built two sources of truth wearing one name. Pushing an invariant down does not relieve the pressure to reimplement it up. Plan for that, or plan to debug it.
One genuinely surprising mechanical detail, because a deep dive earns its keep on these: Postgres stores policy expressions parsed, but function bodies as text. Rename a column and every policy that references it updates automatically — while any function that names it keeps the old text, compiles fine, and raises weeks later at call time, having silently done who-knows-what in between. The lost ORM conveniences everyone frets about are real but the smallest cost here; the debugging is the real tax.
When not to do any of this
The boundary matters as much as the technique. Skip it if you’re genuinely single-tenant, or if isolation between tenants isn’t adversarial — you’re buying a backstop against a threat you don’t have. Skip it with a team that has nobody comfortable reading SQL and the database catalog, because a policy you can’t debug is worse than an app check you can. Skip it if database portability is a real requirement, because this is a deep bet on one engine. And skip it early, while the schema still changes weekly — the policies become drag long before the isolation becomes worth it. This worked because the rules went in while the schema was still young and the tables were still empty; retrofitting row-level security onto a live application that already has data and a crowd of writers is a much harder project than a post like this can make sound.
The part that’s actually the point
Strip away the geometry and the permissions and what’s left isn’t a database trick. It’s about where the truth of a thing can be checked.
When an invariant lives in application code, the only way to know it holds is to read the code and trust that every path goes through it — and you already know one doesn’t. When it lives in the database, you can ask the running system directly: is this true, right now? You can query the database’s own catalog for whether the policy is actually attached to the table, whether the column is really there. That’s a different kind of confidence than reading a file, because a file is a claim about the past. Migration source is immutable history; it can be perfectly correct while the live database has quietly drifted away from it. Grep cannot tell you what’s enforced. Only the thing doing the enforcing can.
It shows up even in the guards you write to keep yourself honest. A check that lists the things it inspects — these functions, those tables — goes stale the day someone adds one it doesn’t know to look at. A check that asks the database for the whole class — every function of this kind, every table that carries data — can’t. Enumerate and you’re trusting a list you have to remember to update. Ask, and you’re trusting the system to tell you about itself. Text about a system is not the system.
I should be plain about what this is and isn’t. It’s a design used in earnest but not proven under real load — the shape was there from the start, the policies went in a few migrations later, and none of it has been hardened over years of traffic. And I’m an enthusiast, not a database professional; this is the best I could do with what I know and could go read, and a real DBA may well tell me I’ve drawn the line in the wrong place. I’d genuinely like to hear it. But the instinct underneath has held up every time I’ve watched it play out: when a rule is really about the data — when it must never be false, no matter which door the write came through — put it where the data is, at the one point every path has to cross.
I learned all this by asking, which is the joke
Everything above, I got the way you get anything about a system you didn’t build yourself: I asked the people who did. Three of them, as it happened — three separate working sessions on the same codebase — and the accounts didn’t line up. One described a working spatial feature that turned out to be an empty column, reserved for later. And one claim named the wrong instrument for a rule that genuinely is enforced — it credited the row-level policies with holding a record immutable, when a trigger was doing that work. The conclusion was right and the mechanism wasn’t, which is the more durable kind of error, because the mechanism is the part the next person copies. That one went into an earlier draft of this post under my name, and I couldn’t tell you now whether I was repeating someone or had assembled it myself. Which is rather the point.
None of it got settled by arguing over who remembered right. It got settled by querying the live schema — the column that really was empty, the trigger that really was doing the work the policy got the credit for, the rule that really was or wasn’t attached to the table. The memory of the system — even held by the people who wrote it, even held by me once I’d read it — had drifted from the system. The database was the only one in the conversation that couldn’t be talked into a version of events.
So take the argument twice: once from the inside, once from the outside. Put the rules that must never be false where every path has to cross them. And when you want to know whether they’re actually holding — whether anything is really the way the notes say it is — don’t ask the notes. Ask the thing that’s running.
# comments (0)
no comments yet — be the first.