The first customer is always easy. The schema is whatever you sketched out on a napkin, the queries are whatever you write, and tenancy is implicit in the fact that there is exactly one tenant. Everything works. Then a second customer arrives, and you face a choice that will compound for the rest of the system's life: where does their data live, and how does the application keep it apart from the first customer's data.
This is the multi-tenancy question. The answer you give shapes deployment topology, backup strategy, performance characteristics, security posture, and migration cost. Most teams pick implicitly, by writing whatever code is in front of them. The teams that get this right pick deliberately, because every later decision is downstream.
Three patterns and one continuum
The textbook taxonomy lists three multi-tenancy patterns: shared database with shared schema, shared database with separate schemas, and separate database per tenant. The textbook is not wrong, but it understates how much these patterns blend in practice. A more useful frame is a continuum from maximum isolation (database per tenant, possibly on dedicated infrastructure) to maximum sharing (one schema with a tenant_id column on every table).
The shared-schema pattern is the one that scales horizontally with the most ease and the lowest per-tenant cost. Every table that holds tenant-owned data gets a tenant_id column. Every query filters by it. Every index includes it. The application carries the current tenant in a request-scoped context, and a middleware layer enforces that the filter is applied on every read and write. This pattern is what most multi-tenant SaaS uses, and what we use across our four products.
The schema-per-tenant pattern provisions a separate Postgres schema for each tenant within a single database. Cross-tenant queries are harder, schema migrations must run N times instead of once, but data isolation is structurally tighter. The pattern fits when tenants are large and stable (enterprise customers with their own compliance requirements), and unmanageable when tenants are small and numerous (consumer products with thousands of free accounts each occupying a schema).
The database-per-tenant pattern takes isolation further: each tenant gets a dedicated database, possibly on dedicated infrastructure. Used by some healthcare and finance products where regulatory separation makes the operational cost worth it. The cost is real: every operational concern (backups, monitoring, deployments, version skew) multiplies by tenant count.
The discipline of the tenant_id filter
The shared-schema pattern only works if the tenant_id filter is applied consistently. The most common failure mode is a developer writing a query without remembering to add it. The fix is structural rather than procedural: do not let the application code touch the database directly. Route every query through a layer that injects the tenant filter automatically.
In practice, this means a request-scoped context object that carries the tenant, and a database session or query builder that consults that context. Postgres row-level security can be configured to enforce the filter at the database level rather than the application layer, by setting a session variable on every connection and writing security policies that check it. This belt-and-suspenders approach catches the case where application code accidentally bypasses the abstraction. The cost is some additional complexity in connection pooling (the session variable must be set on every checkout and cleared on return) and some additional query overhead.
The cross-tenant query is the operation that needs special handling. Reports, admin tooling, and platform-level features need to read across all tenants. The right answer is a separate code path with explicit, audited access. The wrong answer is a query that "just happens" to omit the tenant filter, because that path is one bug away from a privilege escalation.
Tenant isolation beyond data
Data isolation is the conversation everyone has, but it is only one dimension of multi-tenancy. The harder ones come up later.
Performance isolation: a single tenant running expensive queries can starve other tenants on a shared database. The mitigations include per-tenant query timeouts, connection limits per tenant, and rate limits at the API level. Some customers eventually need a guarantee that they will not be impacted by other tenants' load, which forces a move toward dedicated infrastructure for those tenants. The pattern that emerges is a tiered architecture: most tenants on shared infrastructure, large or paying-for-isolation tenants on dedicated.
Configuration isolation: different tenants need different settings, feature flags, integrations, branding. The shared-schema approach extends naturally: a tenant_settings table keyed by tenant_id, with the application reading per-tenant configuration. This is fine until the configuration is large or hot enough that you cannot afford to read it on every request, at which point you cache it in memory with explicit invalidation when settings change.
Operational isolation: when something goes wrong for one tenant, can you investigate without exposing other tenants' data? The discipline here is that logs, error reports, and debugging tools must respect tenancy from day one. Stack traces should not embed tenant data. Error reporting tools should redact or scope by tenant. Customer support tools should provide tenant-scoped views of the system, not raw database access.
Migration is the killer
The reason multi-tenancy choices compound is migration cost. Moving from shared-schema to schema-per-tenant means running a migration that splits one set of tables into N sets, while the application is live. Moving from schema-per-tenant to database-per-tenant means provisioning N databases and migrating each. Moving from database-per-tenant back to shared (which happens when teams realize they cannot afford the operational overhead) means consolidating data while preserving isolation. Each of these is a months-long project that touches every part of the system.
The practical consequence is that the right time to choose multi-tenancy strategy is at the start, before there are tenants whose data needs migrating. The wrong time is after the first enterprise customer demands an isolation guarantee that the current architecture cannot easily provide.
The default we picked
Across DocuMint, CronPing, FlagBit, and WebhookVault, we chose shared-schema with a tenant_id column on every domain table and per-product database isolation. The reasoning: per-product separation is easy because each product is a separate Docker container with its own SQLite database, and within a product, tenants are small and many. Shared-schema gave us the lowest operational complexity and made cross-customer features (analytics, leaderboards, free-tier limits) trivial to implement.
If we ever onboard a tenant that needs database-level isolation, the path is: spin up a separate instance of the product container with a dedicated SQLite file, point that customer's subdomain at it via Caddy, and migrate their data. The shared-schema pattern is a one-way door only if you make it one. With careful column design and explicit tenant_id columns, the migration to per-tenant databases is mechanical rather than fundamental.
What to choose if you are starting today
For most products, start with shared-schema. The operational simplicity is worth a lot, and the pattern scales further than most teams expect. Build the tenant_id discipline into your access layer from day one, including row-level security if you are on Postgres. Do not let cross-tenant queries leak into the main code path. Build admin tooling that respects tenancy from the start.
Move to schema-per-tenant or database-per-tenant only when a specific requirement (compliance, performance isolation, customer demand) forces you to. The migration is painful, but it is finite. The alternative, of starting with maximum isolation and discovering that you cannot afford to maintain it, is worse: you end up with infrastructure costs that scale linearly with customer count and an operational burden that grows faster than the team.
The deeper lesson, repeated in every multi-tenancy debate, is that isolation is not free and sharing is not free. Picking the right point on the continuum is a question about the actual workload, the actual customer base, and the actual operational maturity of the team running the system. The wrong answer is the one chosen for someone else's circumstances.