PostgreSQL Schemas, Tables, and Constraints

PostgreSQL table design starts with namespaces and ownership, not with filegroups or clustered-table choices. The default table is a heap relation. Indexes are added on top of it. Constraints are real schema objects with their own catalog entries. The live stoxx database makes those boundaries visible enough to treat design as an operational decision instead of only a modeling exercise.

Schemas, Namespaces, And Ownership

PostgreSQL | schema model | inspect the current namespace footprint

Read schemas as ownership and naming boundaries

Run this when reviewing naming discipline, medallion layering, or privilege boundaries. It is typically triggered during initial database design or after drift accumulates in a shared database. The query is read-only. Its purpose is to show which non-system schemas actually exist and who owns them.

SELECT nspname AS schema_name,
       nspowner::regrole AS owner_name
FROM pg_namespace
WHERE nspname NOT LIKE 'pg_%'
  AND nspname <> 'information_schema'
ORDER BY nspname;
schema_nameowner_name
bronzepostgres
dbopostgres
demo_stcpostgres
goldpostgres
publicpg_database_owner
silverpostgres

The important PostgreSQL read is that public is owned by the database owner role abstraction, while the medallion schemas are explicitly owned by postgres in this lab. That makes schema ownership an operational control surface, not only a naming convention.

PostgreSQL | schema layering | measure where tables actually live

Verify that layer meaning is visible in the real database

Run this after a database has accumulated objects and the question becomes whether the intended layer model is still legible. It is typically triggered by onboarding, design review, or permission work. The query is read-only. Its purpose is to show schema density and size together so “layering” stays tied to real objects.

SELECT schemaname,
       COUNT(*) AS tables,
       pg_size_pretty(SUM(pg_total_relation_size(format('%I.%I', schemaname, tablename)::regclass))) AS total_size
FROM pg_tables
WHERE schemaname NOT IN ('pg_catalog','information_schema')
GROUP BY schemaname
ORDER BY COUNT(*) DESC, schemaname;
schemanametablestotal_size
bronze123376 kB
silver731 MB
demo_stc6200 kB
gold31344 kB
dbo216 kB

This is the schema-layering story in one table: silver holds fewer tables but materially more data, bronze owns the broader raw footprint, and gold is small and derived. PostgreSQL schemas are doing real organizational work here.

CREATE TABLE, Types, And Nullability

PostgreSQL | table contract | inspect columns, defaults, identity, and generated values

Capture the table contract from the catalogs instead of from memory

Use this when designing a new table or reviewing whether an existing table contract is precise enough. It is typically triggered during model review, migration, or DDL code review. The demo is state-changing but isolated to temporary tables inside one transaction and rolled back at the end. Its purpose is to show how PostgreSQL records type, nullability, defaults, generated columns, and identity behavior.

BEGIN;
 
CREATE TEMP TABLE note03_parent (
  id int PRIMARY KEY,
  code text UNIQUE,
  amount numeric(10,2) CHECK (amount >= 0),
  created_at timestamptz DEFAULT now(),
  amount_bucket text GENERATED ALWAYS AS (
    CASE WHEN amount >= 100 THEN 'large' ELSE 'small' END
  ) STORED
);
 
CREATE TEMP TABLE note03_child (
  id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  parent_id int NOT NULL REFERENCES note03_parent(id),
  note text DEFAULT 'pending'
);
 
SELECT c.relname AS table_name,
       a.attname AS column_name,
       format_type(a.atttypid, a.atttypmod) AS data_type,
       a.attnotnull AS not_null,
       a.attidentity AS identity_kind,
       a.attgenerated AS generated_kind,
       pg_get_expr(d.adbin, d.adrelid) AS default_expr
FROM pg_class AS c
JOIN pg_attribute AS a
  ON a.attrelid = c.oid
 AND a.attnum > 0
 AND NOT a.attisdropped
LEFT JOIN pg_attrdef AS d
  ON d.adrelid = a.attrelid
 AND d.adnum = a.attnum
WHERE c.relname IN ('note03_parent','note03_child')
ORDER BY c.relname, a.attnum;
 
ROLLBACK;
table_namecolumn_namedata_typenot_nullidentity_kindgenerated_kinddefault_expr
note03_childidintegerta
note03_childparent_idintegert
note03_childnotetextf'pending'::text
note03_parentidintegert
note03_parentcodetextf
note03_parentamountnumeric(10,2)f
note03_parentcreated_attimestamp with time zonefnow()
note03_parentamount_buckettextfsCASE WHEN (amount >= (100)::numeric) THEN 'large'::text ELSE 'small'::text END

Three PostgreSQL-specific signals matter here:

Column signalMeaning
identity_kind = 'a'GENERATED ALWAYS AS IDENTITY is in use on note03_child.id
generated_kind = 's'stored generated column, not a virtual expression
default_expr populateddefault is a true catalog-backed expression, not only a UI-side convenience

Constraints

PostgreSQL | constraint catalog | read the real enforcement objects

Inspect primary key, unique, foreign key, and check constraints together

Run this when you need to prove which rules are actually enforced by the engine. It is typically triggered during model review, troubleshooting, or migration mapping. The query is part of the same rolled-back demo context as the previous section. Its purpose is to show constraint definitions as PostgreSQL stores them in pg_constraint.

BEGIN;
 
CREATE TEMP TABLE note03_parent (
  id int PRIMARY KEY,
  code text UNIQUE,
  amount numeric(10,2) CHECK (amount >= 0)
);
 
CREATE TEMP TABLE note03_child (
  id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  parent_id int NOT NULL REFERENCES note03_parent(id)
);
 
SELECT conrelid::regclass::text AS table_name,
       conname,
       contype,
       pg_get_constraintdef(oid) AS constraint_def
FROM pg_constraint
WHERE conrelid::regclass::text IN ('note03_parent','note03_child')
ORDER BY conrelid::regclass::text, conname;
 
ROLLBACK;
table_nameconnamecontypeconstraint_def
note03_childnote03_child_parent_id_fkeyfFOREIGN KEY (parent_id) REFERENCES note03_parent(id)
note03_childnote03_child_pkeypPRIMARY KEY (id)
note03_parentnote03_parent_amount_checkcCHECK ((amount >= (0)::numeric))
note03_parentnote03_parent_code_keyuUNIQUE (code)
note03_parentnote03_parent_pkeypPRIMARY KEY (id)

The contype codes are the fast operator read: p for primary key, u for unique, f for foreign key, and c for check. PostgreSQL stores these as first-class catalog objects, which makes constraint auditing direct and reliable.

Heap Tables And Physical Shape

PostgreSQL | heap baseline | remember that tables are heaps unless you choose otherwise

Read table persistence and table kind from the relation catalog

Run this when translating storage assumptions from engines where the clustered index is the table. It is typically triggered by cross-platform migration or low-level design review. The query is read-only. Its purpose is to show that ordinary PostgreSQL tables are persistent heap relations by default.

SELECT relname,
       relkind,
       relpersistence,
       reltoastrelid::regclass AS toast_table
FROM pg_class
WHERE oid IN (
  'silver.eurostoxx50_ohlcv'::regclass,
  'gold.index_performance'::regclass
)
ORDER BY relname;
relnamerelkindrelpersistencetoast_table
eurostoxx50_ohlcvrp-
index_performancerp-

relkind = 'r' means ordinary table. relpersistence = 'p' means persistent. This is the default physical shape in PostgreSQL. The table is the heap. The index is a separate structure on top of it. There is no SQL Server-style “clustered index as the table” decision here.

Special Table Variants

PostgreSQL | table variants | choose the right specialized table form

Replace SQL Server-specific table features with PostgreSQL-native variants

Use this decision map during design review when a requirement sounds like it came from another engine. The context is architectural. Its purpose is to map the operational need to the actual PostgreSQL feature family.

RequirementPostgreSQL variantOperational read
skip WAL for disposable dataUNLOGGED tablefaster writes, but truncates after crash and does not replicate safely like normal tables
session-scoped scratch spaceTEMP tableisolated per session and dropped automatically
large time-sliced retentionpartitioned tabledeclarative partitioning, not filegroup-backed partition schemes
precomputed query resultmaterialized viewrefresh-managed, not automatically current
remote external data sourceforeign tablePostgreSQL FDW surface, not a native local heap

Safe Evolution Patterns

PostgreSQL | DDL evolution | prefer expand-migrate-contract over risky rewrites

Change contracts in a way application code can survive

The safe pattern is the same one that survives on every serious database platform:

StepWhy
add the new nullable or default-backed column firstlets old and new code coexist
backfill or migrate in controlled batcheskeeps locks and rewrite risk bounded
switch application reads and writesmoves the contract intentionally
remove the obsolete column lateravoids breaking code in the same deployment that introduced the new shape

Column renames and type rewrites are especially risky because PostgreSQL may need table rewrites or application coordination even when the DDL looks small.

PostgreSQL Schemas, Tables, and Constraints Recommendations

PostgreSQL | design rules | keep contracts explicit and storage assumptions correct

Use the engine you actually have

RuleWhy
use schemas as namespace and permission boundariesthey are doing real organizational work in stoxx already
keep nullability intentionalnullable-by-accident columns become contract debt quickly
use generated columns for deterministic derived data onlythey are stored and maintained by the engine, not free
treat identity and sequences as separate design toolsPostgreSQL gives both; choose deliberately
remember the table is a heap by defaultindex strategy is a second decision, not the table shape itself

Next: 04-postgresql-keys-defaults-identity-and-sequences narrows the design focus from general table contracts to row-identity strategy, defaults, identity columns, sequences, UUID generation, and PostgreSQL-specific key selection rules.