Multi-tenant Postgres: how we built a workshop-chain system where branch data never mixes

One system, eleven branches, one database. The hard part was not the features — it was proving that Gdańsk can never see a Kraków job.

SHORT ANSWER

A multi-tenant system is one application and one database serving many independent organisations or branches. In PostgreSQL, isolation is a tenant_id column on every table, Row Level Security policies comparing it against the identifier in the user's token, and a SECURITY DEFINER function for role checks. A safe rollout needs three more things: composite indexes starting with tenant_id, an idempotent background job queue where every job carries its tenant, and automated isolation tests in CI that try to read another organisation's data on every change and must get zero rows.

  • Data isolation relies on a tenant_id column and Row Level Security policies, not on filters in application code.
  • The organisation identifier comes from the token claims, never from a browser-supplied parameter.
  • Roles live in a separate table and are checked by a SECURITY DEFINER function — a role column on the user profile is a vulnerability.
  • Every index starts with tenant_id, otherwise queries scan every branch's data.
  • Background jobs run with service privileges, so they must filter by tenant explicitly and be idempotent.
  • CI isolation tests impersonate a foreign organisation and require zero rows in the response.
  • The first working version of the workshop-chain system shipped in 9 working days.
Illustration: an isometric database split by yellow partition walls with a padlock gate

The phone call that started it

“We have eleven workshops and eleven ways of working. We want one system, but each branch must see only its own data. Except head office, which sees everything.” That was the whole brief.

It sounds like an ordinary job-list app. It is not. The moment eleven companies share one database, a single mistake in a query stops being a typo and becomes a data-protection incident. That reorders the work: isolation first, features second.

We shipped the first working version in 9 working days. Eight went into features. The first one went entirely into the data model and the boundaries.

Three isolation models, and why we picked the middle one

A separate database per branch gives the hardest boundary, but every migration becomes eleven deployments and a head-office report means stitching eleven sources together. For a small business that is a monthly cost nobody wants to pay.

A schema per branch looks elegant on a slide and hurts in practice: migration tools, generated frontend types and the query planner all start to struggle across dozens of schemas.

That left one database, one schema and a tenant_id column on every table, enforced by Row Level Security. It scales to hundreds of branches with a single migration and a single source of truth — provided the policies are written without exceptions.

The boundary lives in the database, not in the app

The most common mistake we find in inherited projects: the organisation filter lives in application code. It works until someone adds an endpoint at 5pm on a Friday and forgets one `where`. Code has dozens of places to slip; the database has one.

So the organisation identifier is read from the user's token, not from a request parameter. A parameter can be swapped in the browser in two seconds; a server-signed claim cannot.

Roles sit in a separate table and are checked by a SECURITY DEFINER function. A role stored on a profile row the user can edit is a ready-made privilege escalation — and we have seen it in production more than once.

  • Every table: a NOT NULL tenant_id column with a foreign key to the organisation.
  • Every table: RLS enabled, with separate policies for select, insert, update and delete.
  • GRANTs issued explicitly — a policy without a grant is not enough.
  • Head office is not “a user without a filter” but a role with its own, clearly written policy.

Performance: where multi-tenant starts to hurt

After a week in production, the job list in the largest branch started loading a second slower than everywhere else. The cause was mundane: an index on the date column alone. The planner had to scan every branch's jobs before RLS discarded the foreign rows.

The rule we have applied ever since, without exception: every index starts with tenant_id, then the sort or filter column. After the change the query dropped from 940 ms to 24 ms on identical data.

The second trap is functions inside policies. If a policy calls a function that is not declared STABLE, Postgres runs it for every row. At tens of thousands of rows that is the difference between “fast” and “the client is calling”.

Background jobs: where isolation usually breaks

Service reminders, nightly head-office reports, parts-wholesaler sync — all of it runs without a signed-in user, so with service privileges that bypass RLS. It is the most delicate part of the whole system.

Our rule: a queued job always carries its tenant_id and never operates on “everything it can find”. The loop over branches lives in the queue code, not in the query — so one branch failing does not stop the other ten.

Every job also has an idempotency key made of tenant, job type and time window. A retry after a network blip will not send the customer a second SMS. Boring, but these details decide whether a system is trusted after a month or known as “the one that sometimes doubles the messages”.

The test we run on every change

Isolation cannot be “checked once”. One table added in a hurry without RLS and the boundary disappears somewhere nobody remembers. So we wrote a suite that impersonates a user from branch A and attempts twenty operations against branch B's data.

The test passes only when reads return zero rows and writes are refused. On top of that, an automated linter walks the schema and fails the build if any public table has RLS disabled or no policy. It is one of those things that costs half a day once and saves a sleepless night.

On the client's side it all looks ordinary: a manager signs in and sees their workshop, the owner sees eleven. This architecture is meant to be invisible — that is precisely its job.

What we would do differently

We would have added a “head office mode” to the tests earlier. For two days we wrote the aggregate reports bypassing policies because it was faster — and had to come back and redo them properly on a dedicated role.

And one product lesson: from day one, show in the interface which branch context you are working in. Not for technical reasons, but because a manager running two workshops will ask within the first hour.

  • AI-First software
  • Performance
  • Automotive

If part of your business still feels stuck in 2005, we should probably talk.

20 minutes. No pitch deck. We'll ask about your business, you'll ask us anything, and by the end of the call you'll know whether this makes sense for you.

(Worst case: 20 minutes of free advice. We can live with that.)

Email: hello@mocne.ai