- Remove per-module projection files (README, architecture, companion, interfaces, model) under design/<module>/ - Add design/documents/ with numbered design docs, references, policies, and manifest - Update design/README.md to describe the directory as a mirror of membcons-db's normative surfaces - Record Decisions 140-141 in companion and glossary; update data-model.md schema organization
12 KiB
PostgreSQL schemas beyond multi-tenancy: a practical production guide
Schema-per-concern is viable in production and used by major platforms like Supabase and PostgREST, but the pattern demands careful tooling choices and explicit trade-off acceptance. The approach delivers real benefits — namespace clarity, security boundaries via GRANT, and alignment with domain-driven design — at the cost of ORM friction, search_path pitfalls with connection poolers, and migration complexity. PostgreSQL itself treats schemas as zero-overhead namespaces internally, meaning cross-schema JOINs carry no performance penalty. The decision ultimately hinges less on database capability and more on whether your application stack can handle multi-schema workflows without constant friction.
The pattern in production: who actually does this
The strongest validation comes from Supabase, which organizes its entire platform around schemas as service boundaries: auth for authentication tables, storage for file metadata, public for user-facing data, extensions for PostgreSQL extensions, and graphql_public when GraphQL is enabled. Each Supabase platform service owns its schema, and users interact across boundaries through foreign keys (e.g., referencing auth.users from public.profiles). This is schema-per-concern at significant scale, running in production across hundreds of thousands of projects.
PostgREST treats schemas as first-class API boundaries. You configure which schemas to expose (db-schemas = "api"), and it generates REST endpoints for every table, view, and function in those schemas. API versioning works through schemas — v1 and v2 schemas with views over underlying tables. PostGraphile takes this further with a battle-tested three-schema pattern: app_public (exposed to GraphQL), app_hidden (same privileges but not exposed in the API), and app_private (secrets like password hashes, accessible only via SECURITY DEFINER functions). Creator Benjie Gillam's rationale: "By moving email and password_hash to a second table [in a private schema] we make it much harder to accidentally select those values."
Beyond these frameworks, individual practitioners report success. One Hacker News commenter summarized years of experience: "I have gotten a long way, with many applications over many years, with one host, one cluster, one database, and many schemas." A 2026 case study documented a modular monolith integrating Jira, Trello, and Asana using trello.*, asana.*, jira.*, and core.* schemas — with the caveat that "retrofitting schemas later is painful" and "schemas shine when complexity is real, not hypothetical."
Cross-schema queries have zero performance overhead
This is the most important technical fact and the one most often misunderstood. Within a single PostgreSQL database, schemas are purely a namespace mechanism in pg_namespace. The query planner resolves table OIDs identically regardless of schema — auth.users JOIN billing.invoices uses the same buffer pool, connection, transaction context, and statistics as a same-schema join. EXPLAIN ANALYZE output is identical. Foreign keys span schemas freely. Indexes and shared buffers work the same way.
Tom Lane and other PostgreSQL core developers have confirmed this on the pgsql-performance mailing list. One 2007 thread reported slowdowns after reorganizing into schemas, but the cause was PL/pgSQL function call overhead per row, not schema separation. Alibaba Cloud benchmarks showed that even with 1,000 schemas and constant SET search_path calls, QPS dropped only from ~270K to ~187K — roughly 0.05ms per SET call. For a module-based design with 5–15 schemas, measurable overhead is effectively zero.
The relcache (relation cache) keys on OID, not schema. Statistics and plan caching are per-table. The only marginal cost is name resolution through search_path for unqualified names — microseconds at most.
Where the friction actually lives: ORMs and migration tools
The real cost of multi-schema PostgreSQL isn't database performance. It's tooling compatibility, and the landscape varies dramatically by stack.
SQLAlchemy has best-in-class support. You can set schema per table (__table_args__ = {"schema": "billing"}), per MetaData object, or dynamically via schema_translate_map at connection time. Version 2.0 fixed a bug where same-named tables in different schemas caused AmbiguousAlias errors in JOINs. The docs explicitly warn against modifying search_path during reflection — use explicit schema= arguments instead.
Prisma reached GA multi-schema support for PostgreSQL. You declare schemas = ["base", "shop"] in the datasource block and annotate each model with @@schema("base"). Prisma Migrate generates CREATE SCHEMA IF NOT EXISTS. The main limitation: model names must be globally unique across schemas, and moving a model between schemas causes a DROP + CREATE rather than ALTER TABLE SET SCHEMA, risking data loss.
Flyway and Liquibase handle multiple schemas well natively. Flyway accepts schemas=schema1,schema2 in configuration; Liquibase offers per-changeset schemaName attributes and separate defaultSchema/liquibaseSchema settings.
Django is the worst case. There is no native schema attribute on models — ticket #6148 has been open since 2007. The workaround involves configuring multiple entries in settings.DATABASES pointing to the same database with different search_path options, plus a database router. Cross-schema queries require raw SQL or the django-tenants library. One developer reported a legacy database with 80 schemas required monkey-patching Django's introspection classes. TypeORM has a known bug where cross-schema foreign constraints regenerate on every migration (GitHub issue #8565). Alembic requires cookbook-level configuration — its --autogenerate with include_schemas=True scans all schemas and tries to delete unrecognized tables without careful include_object filtering.
search_path and connection pooling: the hidden minefield
The search_path setting determines how PostgreSQL resolves unqualified table names. It operates at multiple levels (function, session, role+database, role, database, postgresql.conf), and changes don't apply to existing connections — only new sessions pick up database- or role-level changes. Views hard-code schemas internally regardless of search_path, so dynamic schema switching through views doesn't work.
The critical production issue: SET search_path is dangerous with connection pools. A DEV Community post documented Spring Boot + HikariCP experience where "schema switching using search_path led to inconsistent behavior under load." Because SET search_path is connection-scoped, and poolers like PgBouncer in transaction mode may hand the same backend connection to different requests, you get "schema bleed and wrong-table reads." The fix is either dedicated connection pools per schema, setting schema via JDBC URL parameters (?currentSchema=user_schema), or — the approach experienced practitioners converge on — using fully-qualified names everywhere and avoiding search_path manipulation entirely. As one veteran put it: "One habit that changed between 2007 and now is that I no longer rely on PostgreSQL's search_path for anything."
CVE-2018-1058 demonstrated why search_path is a security surface: malicious users could create functions in public that shadow pg_catalog builtins. PostgreSQL 15 fixed this by revoking CREATE on public by default, but upgraded databases retain old permissions.
Security boundaries are the strongest argument for schemas
Schema-level privileges offer genuinely useful access control. The USAGE privilege on a schema gates even looking up objects within it — without GRANT USAGE ON SCHEMA billing TO app_readonly, that role cannot see billing tables at all. CREATE controls who can add objects. Crucially, USAGE only allows lookup; actual SELECT/INSERT/UPDATE/DELETE permissions still apply per-table.
ALTER DEFAULT PRIVILEGES automates granting on future objects, which solves the common complaint about per-table grant management:
ALTER DEFAULT PRIVILEGES IN SCHEMA billing
GRANT SELECT ON TABLES TO app_readonly;
The recommended production pattern uses NOLOGIN roles as schema owners with application service accounts inheriting needed roles. This creates clean security boundaries:
auth_rw: read/write on auth schemabilling_rw: read/write on billing schemaanalytics_ro: read-only on analytics schemaapp_service: inherits whichever roles it needs
This is materially better than table-name-prefix approaches, where security requires per-table grants with pattern matching — a maintenance burden that grows linearly with table count. The PostgreSQL Wiki explicitly recommends this role-per-schema architecture.
Schemas as a stepping stone in architectural evolution
The DDD community has converged on schemas as an intermediate step between monolith and microservices. Gary Stafford's influential migration pattern describes three stages: single schema (monolith), schema-per-bounded-context (logical separation), and database-per-service (physical separation). Chris Richardson's microservices.io identifies schema-per-service as the middle option between private-tables-per-service (weakest isolation) and database-server-per-service (strongest isolation), noting it "makes ownership clearer."
The key architectural insight: logical schema separation should precede physical separation. Moving tables between schemas within one database is a low-risk, reversible operation — cross-schema queries still work, foreign keys still hold, transactions still span schemas. This lets you validate domain boundaries before committing to independent infrastructure. When a service eventually needs full independence, extracting a schema to a separate database is a well-understood migration path.
An important nuance from Vladik Khononov: bounded contexts define the boundary of the biggest service possible without conflicting models, not the smallest. A PostgreSQL schema might map to a bounded context containing multiple related microservices, not one schema per microservice. Nick Tune argues that sharing databases within a bounded context is "often a good compromise because services within a bounded context are highly cohesive and change for similar reasons."
Practical recommendations and when to use what
The PostgreSQL Wiki's official recommendation is unambiguous: create a single database with multiple named schemas rather than multiple databases. It even suggests removing the public schema entirely. Table-name prefixes are universally discouraged — schemas are "a predefined unit which many tools/commands understand," while prefixes require you to "replicate some level of this functionality yourself."
Extensions deserve a dedicated schema. The pattern CREATE EXTENSION hstore SCHEMA extensions with ALTER DATABASE mydb SET search_path = "$user", public, extensions keeps extension objects organized and avoids namespace collisions.
Conclusion
Schema-per-concern works in production and delivers three concrete benefits: namespace organization that tools understand natively, security boundaries through PostgreSQL's GRANT system, and architectural legibility that maps to domain boundaries. The pattern imposes no database-level performance cost. The real trade-offs are entirely in the application layer: ORM support ranges from excellent (SQLAlchemy, Prisma) to painful (Django), connection pooling requires discipline around search_path, and migration tooling needs explicit multi-schema configuration. The strongest practical advice from experienced users is to use fully-qualified names rather than relying on search_path, plan for schemas from the start rather than retrofitting, and adopt the pattern only when domain complexity genuinely warrants it — not as premature organization for a simple CRUD application.