Summary
Every product that serves more than one customer from one system has to answer one question before anything else: can customer A ever see customer B’s data? This post is about where to put that boundary. The short version: not in your application code.
The usual place, and why it leaks
The common approach is a column. Every table gets a tenant_id, and every query gets a WHERE tenant_id = ? to match. It works, and it is easy to start with.
The problem is that it has to keep working, in every query, forever. A new report, a quick admin screen, a background job written at the end of a long day, a join that pulls in a second table. Each one is a fresh chance to leave the filter out, and the failure is silent: the query returns rows, the page renders, and nobody notices until a customer sees a name they should never have seen.
Code review catches most of these. “Most” is the problem. The boundary that protects your customers from each other should not depend on everyone remembering something every single time.
Move the boundary down a layer
Postgres can enforce this itself, with row-level security. You attach a policy to a table saying which rows the current connection is allowed to see, the connection declares which customer it is acting for, and from then on the database filters every query, whether or not the application remembered to.
Three details matter more than the feature itself.
Force it. By default, a table’s owner is exempt from its own policies, which in practice means your application’s main database user walks straight past them. Forcing row-level security removes that exemption, so no privileged path is left open by accident.
Set the customer on the connection, once. The customer identity comes from the authenticated session, is set on the connection at the start of the request, and is never read from anything the user sent. If it can come from a URL or a request body, you have rebuilt the original problem with extra steps.
Make it the default for new tables. A policy on nine tables out of ten is not a boundary. In our own codebase a new table is not finished until it has the tenant column, a policy, and its name in the list the tests check, all in the same change. That last part is what keeps it true a year later.
What it costs
It is not free, and it is worth being honest about that.
You give up some convenience in migrations and in admin tooling, because a maintenance script now has to say who it is acting for. Debugging gains one more question: is this query returning nothing because the data is missing, or because the policy is doing its job? And the policies are code, which means they need tests, including one that deliberately tries to read another customer’s row and asserts that it comes back empty.
Set against that: the one class of bug you can never ship is the one your database refuses to perform.
The short checklist
- Every table holding customer data has a tenant column, with a foreign key.
- Every such table has a policy, and row-level security is forced, not merely enabled.
- The customer identity is set from the authenticated session, on the connection, never from user input.
- A test asserts that a cross-customer read comes back empty.
- Adding a table and adding its policy happen in the same change, not in a follow-up.
We run this ourselves
This is not a thought experiment for us. Pipelix is our own product, a platform that serves several companies from one system, and it is built this way: isolation forced in Postgres, the customer resolved from the session, and the table list checked by tests so a new table cannot quietly arrive without a policy.

One workspace in Pipelix. Which rows this screen is allowed to show is settled in the database, below the code that builds the page.
It is also the kind of decision that is cheap on day one and expensive in year two. If you are building something that will have a second customer, it is worth getting right before you have one.