Ein Schema in PostgreSQL ist ein Namespace innerhalb einer Datenbank. Es gruppiert Tabellen, Views, Sequenzen, Funktionen und Typen und sorgt dafür, dass es zwei users-Tabellen geben darf, solange sie in verschiedenen Schemas liegen. Wer eine Query schreibt, ohne das Schema explizit zu nennen, vertraut auf den search_path — und genau hier passieren die meisten Überraschungen.
Schema = Namespace
Eine Datenbank besteht aus mehreren Schemas. Jeder Schema-Eintrag ist ein eigener Container für Objekte:
CREATE SCHEMA app;
CREATE TABLE app.orders (id serial primary key, total numeric);
CREATE TABLE app.customers (id serial primary key, email text);Ein Postgres-Cluster bringt von Haus aus mehrere Schemas mit:
| Schema | Zweck |
|---|---|
public | Default-Schema für eigene Objekte |
pg_catalog | System-Tabellen (pg_class, pg_attribute, …) |
information_schema | SQL-Standard-Sichten auf den Katalog |
pg_toast | Interne Speicherung großer Werte (TOAST) |
pg_temp_* | Pro-Session temporäre Schemas |
pg_catalog ist immer verfügbar — auch ohne USAGE-Privileg.
search_path — wer wird zuerst gefunden?
Wenn du SELECT * FROM orders; schreibst, ohne ein Schema zu nennen, sucht Postgres die Tabelle im search_path ab — der Reihe nach.
SHOW search_path;
-- search_path
-- ---------------
-- "$user", public
SET search_path TO app, public;"$user" ist ein Sonderwert: Postgres ersetzt ihn zur Laufzeit durch den Namen der aktuellen Rolle. Wenn ein User alice heißt und ein Schema alice existiert, wird das durchsucht; existiert es nicht, wird der Eintrag still übersprungen.
Mit SET search_path TO app, public; legst du fest: erst in app suchen, dann in public.
Schreibvorgänge (CREATE TABLE foo … ohne Schema-Präfix) landen immer im ersten existierenden Schema des search_path — also bei obigem Setting in app.
search_path persistent setzen
SET gilt nur für die aktuelle Session. Für persistenten Effekt:
ALTER ROLE app_user SET search_path = app, public;ALTER DATABASE myapp SET search_path = app, public;Beide Varianten greifen ab der nächsten Session. Wer sich gerade verbunden hat, hat noch den alten Wert.
Auch in Funktionen kann der search_path lokal gesetzt werden:
CREATE FUNCTION calculate_tax(amount numeric)
RETURNS numeric
LANGUAGE sql
SET search_path = app, pg_temp
AS $$
SELECT amount * (SELECT rate FROM tax_rates LIMIT 1);
$$;Das ist insbesondere bei SECURITY DEFINER-Funktionen Pflicht — sonst öffnet man Lücken (siehe Pitfall unten).
Das public-Schema — Geschichte und PG 15
Bis PostgreSQL 14 hatte das public-Schema sehr permissive Defaults: jede Rolle (PUBLIC) durfte dort CREATE und USAGE. Das war bequem für Einsteiger und gefährlich für alle anderen, die mit mehreren Usern arbeiteten.
Seit PostgreSQL 15 ist public deutlich restriktiver:
Privileg auf public | Bis PG 14 | Ab PG 15 |
|---|---|---|
USAGE | für PUBLIC (jeder) | für PUBLIC (jeder) — unverändert |
CREATE | für PUBLIC (jeder) | nur für Owner (postgres bzw. DB-Owner) |
Praktische Folge nach Major-Upgrade: Anwendungen, die ihre Tabellen im public-Schema anlegen wollen, brauchen entweder ein eigenes Schema (sauberer) oder explizites GRANT CREATE ON SCHEMA public TO app_user;.
Schema-basierte Mandantentrennung
Eine elegante Methode für Multi-Tenancy: ein Schema pro Mandant, alle Tabellen identisch strukturiert.
CREATE SCHEMA tenant_acme;
CREATE SCHEMA tenant_globex;
-- Identisches Schema-Layout in beiden:
CREATE TABLE tenant_acme.orders (id serial primary key, total numeric);
CREATE TABLE tenant_globex.orders (id serial primary key, total numeric);Pro Login-Rolle ein search_path:
CREATE ROLE acme_user LOGIN PASSWORD 'changeme';
ALTER ROLE acme_user SET search_path = tenant_acme;acme_user sieht nur Daten aus tenant_acme — dieselbe Anwendung, dieselben Queries, andere Daten.
Vorteile:
- starke Isolation auf DB-Ebene (kein versehentlicher Cross-Tenant-Zugriff durch fehlerhafte WHERE-Klausel)
- pro-Tenant-Backup einfacher (
pg_dump --schema=tenant_acme) - pro-Tenant-Migration möglich
Nachteile:
- Schema-Updates müssen für jeden Tenant durchgeführt werden (Migrations-Tooling muss das können)
- Anzahl Tenants begrenzt (tausende Schemas können den Katalog aufblähen)
- Cross-Tenant-Reporting wird umständlich
In der Praxis: gut für mittlere Tenant-Zahlen (10–500), nicht für SaaS mit zehntausenden Kunden — dort ist Row-Level Security mit Tenant-ID-Spalte oft besser.
Häufige Stolperfallen
USAGE auf das Schema fehlt — die Klassikerfalle.
GRANT SELECT ON ALL TABLES IN SCHEMA app TO reader; reicht nicht. Ohne GRANT USAGE ON SCHEMA app TO reader; bekommt der Reader weiterhin „permission denied for schema app”. Das passiert besonders gerne, wenn man Predefined Roles wie pg_read_all_data umgehen will und manuell granted.
search_path und SECURITY DEFINER — das Sicherheitsleck.
Eine SECURITY DEFINER-Funktion läuft mit den Rechten des Owners. Wenn der search_path zur Aufrufzeit eine schadhafte Schema-Reihenfolge enthält, kann ein Angreifer eigene Funktionen vorne schmuggeln und die Owner-Rechte kapern. Lösung: in jeder SECURITY DEFINER-Funktion SET search_path = pg_catalog, pg_temp (oder explizit nur die Schemas, die du brauchst) setzen.
Schema-Renaming bricht Anwendungs-Code.
ALTER SCHEMA tenant_acme RENAME TO tenant_acme_corp; ist syntaktisch trivial — aber jede Connection-Konfiguration und jeder hardcodierte Schema-Name (z. B. in Migrationsskripten oder Views) bricht. Vor dem Rename die Codebase grep’pen.
DROP SCHEMA … CASCADE entfernt ALLES darin.
Inklusive Tabellen mit Daten, Sequenzen, Funktionen, Trigger. Das ist gewollt, aber gerne unterschätzt. Vor CASCADE ein Backup machen oder mit DROP SCHEMA tenant_acme; (ohne CASCADE) prüfen, was sich beschwert — das gibt einen Eindruck vom Umfang.
Default-Privileges sind pro Schema — nicht magisch global.
ALTER DEFAULT PRIVILEGES IN SCHEMA app … greift nur für Tabellen, die in app entstehen. Wer in tenant_acme neue Tabellen erstellt, braucht eine separate Default-Privilege-Definition. Bei Multi-Tenant-Setups ist das ein Bootstrap-Schritt pro neuem Tenant.
search_path mit nicht-existierenden Schemas wirft keinen Fehler.
SET search_path = analytics, app, public; — wenn analytics nicht existiert, geht das durch ohne Warnung. Erst bei der nächsten Query, die einen Identifier sucht, wird übersprungen. Bei Refactoring von Schemas keine RAISE WARNING zu erwarten — manueller \dn-Check schadet nicht.
Weiterführende Ressourcen
Externe Quellen
- Schemas – PostgreSQL Documentation
- The Schema Search Path
- SET search_path – PostgreSQL Documentation
- Release Notes 15: public schema permissions
- Writing SECURITY DEFINER Functions Safely