Schemas, tables, and types
A PostgreSQL server can contain multiple databases. Inside one database, schemas group tables and information_schema lets you inspect the shape of what exists. Being able to introspect a database this way is useful in real life, when you join a project and need to learn its schema, and in the next lesson, where we build on these tables.
List schemas (empty database)
A schema groups database objects such as tables inside one database. Given a fresh Postgres database where no migration has run yet, information_schema lets you discover what schemas already exist.
-- pg_% schemas (pg_catalog, pg_toast, ...) are Postgres' own internals, not
-- anything a migration created, so we filter them out to see only real schemas.
SELECT schema_name
FROM information_schema.schemata
WHERE schema_name NOT LIKE 'pg_%'
ORDER BY schema_name;Every fresh Postgres database starts with exactly these 2 schemas: public, the default schema new tables land in when you don't name one, and information_schema, the standard views you just queried to list schemas.
Create the schemas (migration)
With CREATE SCHEMA we can pick a name other than the default public for a schema. Most of the time, a service's migrations only need one schema, but as this example shows, it's possible to create more than one. Then we can prefix table names with the schema name, like schema_name.table_name, to say which schema we mean.
CREATE SCHEMA IF NOT EXISTS library;
CREATE SCHEMA IF NOT EXISTS lending;
CREATE TABLE IF NOT EXISTS library.authors (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
country TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS library.books (
id SERIAL PRIMARY KEY,
author_id INTEGER NOT NULL REFERENCES library.authors (id),
title TEXT NOT NULL,
published_year INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS lending.loans (
id SERIAL PRIMARY KEY,
book_id INTEGER NOT NULL REFERENCES library.books (id),
borrower_name TEXT NOT NULL,
borrowed_at TIMESTAMP NOT NULL,
returned_at TIMESTAMP
);IF NOT EXISTS makes this migration idempotent: running it again is a no-op instead of an error, so it's safe to apply it over and over.
List schemas (after migration)
The migration above was just applied twice in a row before this query ran, and nothing broke. Same query as before, now against a migrated database.
-- pg_% schemas (pg_catalog, pg_toast, ...) are Postgres' own internals, not
-- anything a migration created, so we filter them out to see only real schemas.
SELECT schema_name
FROM information_schema.schemata
WHERE schema_name NOT LIKE 'pg_%'
ORDER BY schema_name;library and lending now show up alongside the two defaults.
Setting the default schema
Yes, you can change which schema unqualified names resolve to: SET search_path picks the search order. Here we point it at lending, so loans below means lending.loans without spelling it out.
SET search_path TO lending, public;
SELECT * FROM loans;search_path defaults to "$user", public, which is why public is where unqualified names land until you change it. This only affects the current session's name lookup — lending.loans is still reachable however search_path is set.
A cross-schema join
Joining across schemas works exactly like joining tables in the same schema — a schema is a naming namespace, not a query boundary. This finds which book each loan is for.
SELECT b.title, l.borrower_name, l.borrowed_at, l.returned_at
FROM library.books b
JOIN lending.loans l ON l.book_id = b.id
ORDER BY l.borrowed_at;This is easy because one migration owns both schemas here. If lending belonged to a separate service instead, this same join would tie your query to another team's internal tables.
List tables
information_schema.tables shows the tables visible in each schema.
SELECT table_schema, table_name
FROM information_schema.tables
WHERE table_schema IN ('library', 'lending')
AND table_type = 'BASE TABLE'
ORDER BY table_schema, table_name;Inspect table shape
information_schema.columns shows each column name, type, and nullability.
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'library'
AND table_name = 'books'
ORDER BY ordinal_position;Putting it together
You don't need to read the migration to know the shape of these tables. Joining information_schema.tables and information_schema.columns reconstructs every column, type, and nullability across both schemas, straight from what Postgres itself tracks.
Watch the column_default column below: it holds the literal default expression Postgres runs whenever a column is left out of an INSERT. For an id column it'll read something like nextval('lending.loans_id_seq'::regclass) — that's what SERIAL actually is. Declaring a column SERIAL silently creates a sequence (a sequence is just a relation) named table_column_seq by convention (here, lending.loans_id_seq) and points the column's default at nextval() on it. The ::regclass cast is there because nextval() takes a regclass argument — a reference to the sequence by object identity (its OID), not by a plain text name — so the default keeps working even if the sequence or its schema gets renamed; Postgres just prints it back as whatever name currently resolves to that OID.
One thing is still missing: information_schema.columns doesn't say which column is the primary key. Here are three ways to get that.
SELECT
t.table_schema,
t.table_name,
c.column_name,
c.data_type,
c.is_nullable,
c.column_default
FROM information_schema.tables t
JOIN information_schema.columns c
ON c.table_schema = t.table_schema
AND c.table_name = t.table_name
WHERE t.table_schema IN ('library', 'lending')
AND t.table_type = 'BASE TABLE'
ORDER BY t.table_schema, t.table_name, c.ordinal_position;Finding primary keys: information_schema
The portable, ANSI-SQL-standard way — works the same on any SQL database, not just Postgres.
SELECT tc.table_schema, tc.table_name, kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON kcu.constraint_name = tc.constraint_name
AND kcu.table_schema = tc.table_schema
WHERE tc.constraint_type = 'PRIMARY KEY'
AND tc.table_schema IN ('library', 'lending')
ORDER BY tc.table_schema, tc.table_name;Finding primary keys: pg_constraint
Postgres' own catalog, the one information_schema is built on top of. Not portable, but simpler and faster.
SELECT conrelid::regclass AS table_name, pg_get_constraintdef(oid) AS definition
FROM pg_constraint
WHERE contype = 'p'
AND connamespace IN ('library'::regnamespace, 'lending'::regnamespace)
ORDER BY conrelid::regclass::text;Finding primary keys: pg_index
A primary key is always backed by a unique index, so you can find it there too — useful when you care about the index itself.
SELECT n.nspname AS table_schema, c.relname AS table_name, a.attname AS column_name
FROM pg_index i
JOIN pg_class c ON c.oid = i.indrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = ANY(i.indkey)
WHERE i.indisprimary
AND n.nspname IN ('library', 'lending')
ORDER BY table_schema, table_name;What We Learned
- Databaseis a separate PostgreSQL collection of schemas, tables, data, and other objects.
- Schemais a namespace inside a database. public is the default schema in a new database.
- information_schemacontains portable metadata views for discovering schemas, tables, and columns.
- schema.tableis a schema-qualified table name. It removes ambiguity when tables live outside public.
- search_pathcontrols which schema Postgres searches first for unqualified names. SET search_path changes it for the session.
- PRIMARY KEYidentifies the column(s) that uniquely and non-null identify each row.
- Sequenceis its own database object that generates an ordered series of unique numbers, independent of any table.
- SERIALis shorthand for an integer column backed by a sequence-generated default. It isn't a real type of its own.
- nextvaladvances a sequence and returns its next value. It's what a SERIAL column's default calls.
- column_defaultis the information_schema.columns field holding a column's literal default expression, such as a SERIAL column's nextval(...) call.
- regclassis an OID alias type: a reference to a table, sequence, or other relation by identity rather than by name, displayed as whatever name currently resolves to it.
- pg_indexis the Postgres catalog behind every index, including the unique index that backs a primary key.
- pg_constraintis the Postgres catalog that information_schema's constraint views are built from.