Postgres Schema vs Database: The Difference Explained
Chat2DB TeamNewcomers to PostgreSQL — especially from MySQL, where CREATE DATABASE and CREATE SCHEMA are literally synonyms — regularly pick the wrong container and pay for it later: analytics queries that can't join "across databases," migrations pointed at the wrong tenant, or a public schema free-for-all. In PostgreSQL the two are genuinely different objects with different boundaries. Here's the mental model, the syntax, and how to choose between them for real designs like multi-tenancy.
The hierarchy
A PostgreSQL server (cluster) contains databases; each database contains schemas; each schema contains tables, views, functions, sequences and types:
cluster (one postgres instance, one port)
├── database: app_prod
│ ├── schema: public
│ │ └── users, orders, ...
│ ├── schema: billing
│ │ └── invoices, payments
│ └── schema: audit
├── database: app_staging
└── database: postgres (default admin DB)The rules that follow from this:
- A connection is to one database. You connect to
app_prod, never to "the cluster." Inside that connection you can see every schema you have privileges on. - Queries can join across schemas, not across databases.
SELECT ... FROM billing.invoices JOIN public.users ...is normal SQL. Joining to a table inapp_stagingfromapp_prodrequires an extension (postgres_fdwordblink) — there is no cross-database query in core. - Users/roles are cluster-wide. A role exists once per cluster; what varies per database and schema is its privileges.
The syntax side by side
-- Databases: created rarely, usually by an admin
CREATE DATABASE app_prod;
\c app_prod -- psql: reconnect to it (new connection!)
-- Schemas: created freely inside the current database
CREATE SCHEMA billing;
CREATE SCHEMA IF NOT EXISTS audit AUTHORIZATION audit_owner;
CREATE TABLE billing.invoices (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
total numeric NOT NULL
);
SELECT * FROM billing.invoices; -- qualified reference
DROP SCHEMA audit CASCADE; -- drops contained objects tooInspection:
\l -- psql: list databases (SELECT datname FROM pg_database;)
\dn -- psql: list schemas (SELECT nspname FROM pg_namespace;)
\dt billing.* -- tables in one schema
SELECT current_database(), current_schema;search_path: how unqualified names resolve
When you write SELECT * FROM users without a schema, PostgreSQL walks the search_path:
SHOW search_path; -- default: "$user", public
SET search_path TO billing, public; -- session
ALTER ROLE analyst SET search_path = reporting, public; -- per role
ALTER DATABASE app_prod SET search_path = app, public; -- per databaseThe default "$user", public means: first a schema named like your role (if it exists), then public. Two practical consequences:
- Convenient defaults: everything lands in
publicunless you say otherwise, which is why small projects never notice schemas exist. - A footgun in functions:
SECURITY DEFINERfunctions should always pinSET search_path = ...so an attacker can't inject a shadowing table earlier in the path. (Temp schemas are searched first, implicitly.)
Note that since PostgreSQL 15, ordinary users no longer get CREATE on public by default — a deliberate hardening. Grant it back explicitly only if you mean to: GRANT CREATE ON SCHEMA public TO app_rw;.
What each boundary actually isolates
| Property | Separate databases | Separate schemas |
|---|---|---|
| Joins / FK constraints across them | ✗ (needs FDW; no cross-DB FKs at all) | ✓ plain SQL |
| One connection reaches both | ✗ (one DB per connection) | ✓ |
| Shared roles | ✓ (roles are cluster-wide) | ✓ |
| Independent backup/restore | ✓ pg_dump dbname | ◐ pg_dump -n schema (same DB still shares WAL etc.) |
| Extensions installed independently | ✓ per database | ◐ extension objects live in a schema, one install per DB |
| Accidental cross-access possible | Hard (must reconnect) | Yes, if privileges allow |
| Connection pool efficiency | One pool per database | One pool total |
That last row matters more than people expect: PgBouncer pools are per (database, user) pair. A hundred tenant databases means a hundred pools and poor connection reuse; a hundred tenant schemas share one pool.
Choosing for multi-tenancy
The classic three options, in increasing isolation and cost:
- Shared schema, tenant_id column + Row-Level Security. One set of tables; RLS policies enforce isolation. Cheapest to operate, migrations run once, and it scales to huge tenant counts. See our row-level security guide.
- Schema per tenant. Real namespace isolation, per-tenant restore is possible (
pg_dump -n tenant_042), joins to shared reference data still work, one connection pool. Cost: migrations × tenants, catalog growth at thousands of schemas, and application logic to setsearch_pathper request — carefully, since a bug leaks across tenants. - Database per tenant. Strongest isolation (no query can cross it), independent extensions and collations. Cost: connection-pool fragmentation, migrations × tenants, cross-tenant analytics requires FDW gymnastics or ETL.
Sound defaults: RLS for SaaS with many small tenants; schema-per-tenant for dozens-to-hundreds of mid-size tenants with per-tenant restore requirements; database-per-tenant for a handful of large, compliance-separated customers.
Schemas as code organization
Even single-tenant apps benefit from schemas as modules:
CREATE SCHEMA app; -- core tables
CREATE SCHEMA audit; -- triggers write here; app role has INSERT only
CREATE SCHEMA reporting; -- views only; BI role gets USAGE here and nothing else
GRANT USAGE ON SCHEMA reporting TO bi_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA reporting TO bi_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA reporting
GRANT SELECT ON TABLES TO bi_reader;Privileges become structural: the BI tool physically cannot see app.users, and audit rows can't be updated by the app role. Remember the two-level rule — a role needs USAGE on the schema and privileges on the object; missing USAGE yields "permission denied for schema," which is one of the most-Googled Postgres errors.
Common mistakes
- Creating a database per environment on one cluster (
app_dev,app_testnext toapp_prod) — fine functionally, but they share memory, WAL and crash domain. Environments deserve separate clusters. - Expecting
USE databasemid-session like MySQL. There's no such statement;\cin psql opens a new connection. - Cross-database foreign keys. Impossible, full stop. If two datasets need referential integrity, they belong in one database (different schemas are fine).
- Dumping the whole cluster when you meant one schema.
pg_dump -n billing app_prodscopes to a schema;pg_dumpallgrabs the world.
Navigating databases and schemas is exactly where a good GUI pays off: Chat2DB (opens in a new tab) shows the cluster → database → schema → table tree explicitly, lets you run cross-schema queries with autocomplete on qualified names, and its AI assistant respects your current search_path when generating SQL. It also runs in the browser at app.chat2db.ai (opens in a new tab).
FAQ
Is a schema the same as a database in MySQL?
In MySQL, yes — CREATE SCHEMA is an alias for CREATE DATABASE, and "cross-database" joins work because MySQL databases are really schemas in one namespace. PostgreSQL separates the concepts: databases are hard connection boundaries, schemas are namespaces inside them. Port MySQL's db1.table habits to Postgres schemas, not databases.
Can I move a table between schemas?
ALTER TABLE public.users SET SCHEMA app; — instant, it only updates the catalog. Update search_path or qualified references afterward. Moving between databases means dump/restore or logical replication.
How many schemas can a database hold?
There's no practical limit for sane designs; deployments run thousands. Catalog bloat and pg_dump duration grow with object count, so tens of thousands of schemas × many tables each is where schema-per-tenant designs start hurting and RLS designs shine.
