PostgreSQL table design
Core rules
- Use modern PostgreSQL 18 features (virtual columns, uuidv7) if possible.
- Prefer
text with check (length(field) <= N) over varchar(N).
- Define a primary key for reference tables (e.g., users, orders). Not needed for time-series, event, or log data. When used, prefer
bigint generated always as identity; use uuid only for global uniqueness or when opacity is required.
- Normalize to 3NF initially to remove redundancy and update anomalies. Denormalize only for proven, high-ROI reads where join performance is an issue. Premature denormalization increases maintenance.
- Add
not null constraints where semantically needed, and use default values for common cases.
- Create indexes for actual query access paths: PK/unique (auto), FK columns (manual), frequent filters/sorts, and join keys.
- Prefer
timestamptz for event times, numeric for money, text for strings, bigint for integers, double precision for floats (or numeric for exact decimal math).
PostgreSQL "Gotchas"
- Identifiers: Unquoted names are lowercased. Avoid quoted/mixed-case. Convention: use
snake_case for tables and columns.
- SQL keywords: lowercase.
- Unique + NULLs:
unique allows multiple NULLs. Use unique (...) nulls not distinct (PG 15+) to allow a single NULL.
- FK indexes: PostgreSQL does not auto-index FK columns; add indexes manually.
- No silent coercions: Overflows raise errors (e.g., 999 into
numeric(2,0) fails). No silent truncation/rounding.
- Sequences/identity gaps are normal due to rollbacks and concurrency. Do not "fix" gaps.
- Heap storage: No clustered PK by default.
cluster is a one-off operation and isn't maintained after further inserts. Row order on disk follows insertion unless explicitly clustered.
- MVCC: Updates/deletes leave dead rows.
vacuum cleans them. Avoid wide-row and high-churn designs when possible.
Data types
- IDs: Prefer
bigint generated always as identity; use uuid when federating/distributing or for opaque IDs. Use uuidv7(); use gen_random_uuid() for PostgreSQL 17 and earlier.
- Integers: Use
bigint unless space matters, then use integer or smallint as appropriate.
- Floats: Use
double precision. Use real only for critical storage constraints. Use numeric for decimals needing full precision.
- Strings: Use
text. For limits, use check (length(col) <= n), not varchar(n); avoid char(n). Use bytea for binary. Large strings/binary (>2KB) are TOASTed. Default extended is optimal. Use collation/expression indexes on lower(col) or citext for case-insensitivity.
- Money: Use
numeric(p, s), not float.
- Time: Use
timestamptz for timestamps, date for dates, interval for durations. Avoid timestamp (without timezone). Use now() for transaction time, clock_timestamp() for wall time.
- Booleans: Use
boolean with not null if appropriate.
- Enums: Use
create type ... as enum for stable sets. For evolving values, use text/int plus check or lookup tables.
- Arrays: Use standard array types for ordered lists. Index with GIN for containment and overlap. Suitable for tags and categories; for relations, use junction tables. Literals:
'{val1,val2}' or array[val1,val2].
- Range types: Use
daterange, numrange, tstzrange for intervals; index with GiST.
- Network types: Use
inet, cidr, macaddr for IP/network/MAC.
- Geometric types: Use
point, line, polygon, circle; index with GiST. For advanced needs, use PostGIS.
- Text search:
tsvector for docs, index with GIN. Specify language in to_tsvector()/to_tsquery().
- Domain types: Use for reusable custom types with validation.
- Composite types: Use for structured columns; access as
(col).field.
- JSONB: Preferred for flexible/optional fields; index with GIN. Use JSON only if order matters.
- Vector types: Use
vector from pgvector for embeddings/similarity search.
Avoid these data types
- Do not use
timestamp (no timezone); use timestamptz.
- Do not use
char(n) or varchar(n); use text.
- Do not use the
money type; use numeric.
- Do not use
timetz; use timestamptz.
- Do not use precision like
timestamptz(0); use timestamptz.
- Do not use
serial; use generated always as identity.
Table types
- Regular: Standard, fully durable/logged.
temporary: Session-scoped, not logged, auto-dropped.
unlogged: Fast, not crash-safe. Use for cache/staging.
Row-level security
Enable with alter table tbl enable row level security, then set policies:
create policy user_access on orders for select to app_users using (user_id = current_user_id());
Constraints
- primary key: Implies unique + not null; adds B-tree index.
- Foreign key: Specify
on delete/on update actions. Index referencing columns explicitly. For circular dependencies, use deferrable initially deferred.
- unique: B-tree index; allows multiple NULLs (unless
nulls not distinct, PG15+). Prefer nulls not distinct unless duplicate NULLs are needed.
- check: Row-level constraints; NULLs pass unless combined with
not null.
- exclude: Prevents overlaps using operators; needs suitable index (often GiST).
Indexing
- B-tree: Default; for equality/range queries/order by.
- Composite: Index uses leftmost-constrained columns first. Place most-filtered columns first.
- Covering: Use
include for index-only scans.
- Partial: For hot subsets, e.g.,
status='active'.
- Expression: Use for computed search keys;
where must match expression.
- GIN: Use for JSONB, arrays, text search.
- GiST: Use for ranges, geometry, exclusion constraints.
- BRIN: For huge, naturally ordered data (e.g., time-series).
Partitioning
- Use for very large tables (>100M rows) with partition-key filtering.
- Useful for periodic pruning or bulk replace.
- Range: For time-series:
partition by range (created_at) and create partitions per period.
- TimescaleDB automates this.
- List: For discrete values:
partition by list (region).
- Hash: For even distribution when no natural key:
partition by hash (user_id).
- Only declarative partitioning; avoid table inheritance.
- Limitations: No global uniques—include partition key. No FKs from partitioned tables; use triggers.
Special considerations
Update-heavy tables
- Separate frequently updated ("hot") columns into a different table.
- Set
fillfactor=90 to support HOT updates.
- Avoid updating indexed columns.
- Partition based on update patterns.
Insert-heavy workloads
- Minimize indexes; create only as needed.
- Use
copy or multi-row insert for bulk loads.
- Use unlogged tables for staging when possible.
- Defer index creation on large loads: drop, load, then recreate.
- Partition by time/hash to distribute load; TimescaleDB can automate this.
- Use a natural key as PK if possible; insert-heavy tables may not need a PK. If surrogate needed, prefer
bigint generated always as identity.
Upsert-friendly design
on conflict (col1, col2) needs a matching unique index (not partial).
- Use
excluded.column for referenced values and only update changed columns.
do nothing is faster than do update if no changes required.
Safe schema evolution
- Most DDL is transactional (can roll back).
- Use
create index concurrently for non-blocking index creation; not in transactions.
- Adding
not null with volatile defaults rewrites entire table. Non-volatile is fast.
- Drop constraints before dropping columns to avoid errors.
- Changing function signatures creates overloads. Drop old as needed.
Generated columns
- Use
generated always as (...) stored for computed, indexable columns. Use virtual for columns that don't need to be stored.
Extensions
- pgcrypto: Password hashing.
- pg_trgm: Fuzzy search; GIN indexes for
like/ilike.
- citext: Case-insensitive text type; prefer expression indexes unless strict constraints required.
- btree_gin/btree_gist: Mixed-type indexes.
- hstore: Key-value pairs; JSONB preferred generally.
- timescaledb: Time-series automation.
- postgis: Geospatial features.
- pgvector: Vector search.
- pgaudit: Audit logging.
JSONB guidance
- Prefer
jsonb with GIN index:
- Containment:
jsonb_col @> '{"k":"v"}'
- Key existence:
jsonb_col ? 'k', any/all: ?|, ?&
- Path containment for nested docs
- Use
jsonb_path_ops GIN for heavy containment-only; disallows key existence ops.
- For equality/range: extract scalar to generated/expression column and index as B-tree.
- Prefer B-tree range queries over runtime casts.
- Use GIN with
@> for JSONB arrays when needed.
- Keep core data as regular columns; reserve JSONB for optional/variable fields.
- Add constraints like
check(jsonb_typeof(config) = 'object') for safety.
Examples
Users
create table users (
user_id bigint generated always as identity primary key,
email text not null unique,
name text not null,
created_at timestamptz not null default now()
);
create unique index on users (lower(email));
create index on users (created_at);
Orders
create table orders (
order_id bigint generated always as identity primary key,
user_id bigint not null references users(user_id),
status text not null default 'PENDING' check (status in ('PENDING','PAID','CANCELED')),
total numeric(10,2) not null check (total > 0),
created_at timestamptz not null default now()
);
create index on orders (user_id);
create index on orders (created_at);
JSONB
create table profiles (
user_id bigint primary key references users(user_id),
attrs jsonb not null default '{}',
theme text generated always as (attrs->>'theme') stored
);
create index profiles_attrs_gin on profiles using gin (attrs);
1---2name: postgresql-table-design3description: Design and optimize a PostgreSQL-specific schema. Use for PostgreSQL best practices, data types, indexing, constraints, performance patterns, and advanced features.4---56# PostgreSQL table design78## Core rules910- Use modern PostgreSQL 18 features (virtual columns, uuidv7) if possible.11- Prefer `text` with `check (length(field) <= N)` over `varchar(N)`.12- Define a **primary key** for reference tables (e.g., users, orders). Not needed for time-series, event, or log data. When used, prefer `bigint generated always as identity`; use `uuid` only for global uniqueness or when opacity is required.13- **Normalize to 3NF** initially to remove redundancy and update anomalies. Denormalize only for proven, high-ROI reads where join performance is an issue. Premature denormalization increases maintenance.14- Add **`not null`** constraints where semantically needed, and use **`default`** values for common cases.15- Create **indexes for actual query access paths**: PK/unique (auto), **FK columns (manual)**, frequent filters/sorts, and join keys.16- Prefer **`timestamptz`** for event times, **`numeric`** for money, **`text`** for strings, **`bigint`** for integers, **`double precision`** for floats (or `numeric` for exact decimal math).1718## PostgreSQL "Gotchas"1920- **Identifiers**: Unquoted names are lowercased. Avoid quoted/mixed-case. Convention: use `snake_case` for tables and columns.21- **SQL keywords**: lowercase.22- **Unique + NULLs**: `unique` allows multiple NULLs. Use `unique (...) nulls not distinct` (PG 15+) to allow a single NULL.23- **FK indexes**: PostgreSQL does not auto-index FK columns; add indexes manually.24- **No silent coercions**: Overflows raise errors (e.g., 999 into `numeric(2,0)` fails). No silent truncation/rounding.25- **Sequences/identity gaps** are normal due to rollbacks and concurrency. Do not "fix" gaps.26- **Heap storage**: No clustered PK by default. `cluster` is a one-off operation and isn't maintained after further inserts. Row order on disk follows insertion unless explicitly clustered.27- **MVCC**: Updates/deletes leave dead rows. `vacuum` cleans them. Avoid wide-row and high-churn designs when possible.2829## Data types3031- **IDs**: Prefer `bigint generated always as identity`; use `uuid` when federating/distributing or for opaque IDs. Use `uuidv7()`; use `gen_random_uuid()` for PostgreSQL 17 and earlier.32- **Integers**: Use `bigint` unless space matters, then use `integer` or `smallint` as appropriate.33- **Floats**: Use `double precision`. Use `real` only for critical storage constraints. Use `numeric` for decimals needing full precision.34- **Strings**: Use `text`. For limits, use `check (length(col) <= n)`, not `varchar(n)`; avoid `char(n)`. Use `bytea` for binary. Large strings/binary (>2KB) are TOASTed. Default `extended` is optimal. Use collation/expression indexes on `lower(col)` or `citext` for case-insensitivity.35- **Money**: Use `numeric(p, s)`, not float.36- **Time**: Use `timestamptz` for timestamps, `date` for dates, `interval` for durations. Avoid `timestamp` (without timezone). Use `now()` for transaction time, `clock_timestamp()` for wall time.37- **Booleans**: Use `boolean` with `not null` if appropriate.38- **Enums**: Use `create type ... as enum` for stable sets. For evolving values, use `text`/`int` plus `check` or lookup tables.39- **Arrays**: Use standard array types for ordered lists. Index with **GIN** for containment and overlap. Suitable for tags and categories; for relations, use junction tables. Literals: `'{val1,val2}'` or `array[val1,val2]`.40- **Range types**: Use `daterange`, `numrange`, `tstzrange` for intervals; index with **GiST**.41- **Network types**: Use `inet`, `cidr`, `macaddr` for IP/network/MAC.42- **Geometric types**: Use `point`, `line`, `polygon`, `circle`; index with **GiST**. For advanced needs, use **PostGIS**.43- **Text search**: `tsvector` for docs, index with **GIN**. Specify language in `to_tsvector()`/`to_tsquery()`.44- **Domain types**: Use for reusable custom types with validation.45- **Composite types**: Use for structured columns; access as `(col).field`.46- **JSONB**: Preferred for flexible/optional fields; index with **GIN**. Use JSON only if order matters.47- **Vector types**: Use `vector` from `pgvector` for embeddings/similarity search.4849### Avoid these data types5051- Do not use `timestamp` (no timezone); use `timestamptz`.52- Do not use `char(n)` or `varchar(n)`; use `text`.53- Do not use the `money` type; use `numeric`.54- Do not use `timetz`; use `timestamptz`.55- Do not use precision like `timestamptz(0)`; use `timestamptz`.56- Do not use `serial`; use `generated always as identity`.5758## Table types5960- **Regular**: Standard, fully durable/logged.61- **`temporary`**: Session-scoped, not logged, auto-dropped.62- **`unlogged`**: Fast, not crash-safe. Use for cache/staging.6364## Row-level security6566Enable with `alter table tbl enable row level security`, then set policies:67```sql68create policy user_access on orders for select to app_users using (user_id = current_user_id());69```7071## Constraints7273- **primary key**: Implies unique + not null; adds B-tree index.74- **Foreign key**: Specify `on delete`/`on update` actions. Index referencing columns explicitly. For circular dependencies, use `deferrable initially deferred`.75- **unique**: B-tree index; allows multiple NULLs (unless `nulls not distinct`, PG15+). Prefer `nulls not distinct` unless duplicate NULLs are needed.76- **check**: Row-level constraints; NULLs pass unless combined with `not null`.77- **exclude**: Prevents overlaps using operators; needs suitable index (often GiST).7879## Indexing8081- **B-tree**: Default; for equality/range queries/order by.82- **Composite**: Index uses leftmost-constrained columns first. Place most-filtered columns first.83- **Covering**: Use `include` for index-only scans.84- **Partial**: For hot subsets, e.g., `status='active'`.85- **Expression**: Use for computed search keys; `where` must match expression.86- **GIN**: Use for JSONB, arrays, text search.87- **GiST**: Use for ranges, geometry, exclusion constraints.88- **BRIN**: For huge, naturally ordered data (e.g., time-series).8990## Partitioning9192- Use for very large tables (>100M rows) with partition-key filtering.93- Useful for periodic pruning or bulk replace.94- **Range**: For time-series: `partition by range (created_at)` and create partitions per period.95 - **TimescaleDB** automates this.96- **List**: For discrete values: `partition by list (region)`.97- **Hash**: For even distribution when no natural key: `partition by hash (user_id)`.98- Only declarative partitioning; avoid table inheritance.99- **Limitations**: No global uniques—include partition key. No FKs from partitioned tables; use triggers.100101## Special considerations102103### Update-heavy tables104105- Separate frequently updated ("hot") columns into a different table.106- Set `fillfactor=90` to support HOT updates.107- Avoid updating indexed columns.108- Partition based on update patterns.109110### Insert-heavy workloads111112- Minimize indexes; create only as needed.113- Use `copy` or multi-row `insert` for bulk loads.114- Use **unlogged tables** for staging when possible.115- Defer index creation on large loads: drop, load, then recreate.116- Partition by time/hash to distribute load; TimescaleDB can automate this.117- Use a natural key as PK if possible; insert-heavy tables may not need a PK. If surrogate needed, prefer `bigint generated always as identity`.118119### Upsert-friendly design120121- `on conflict (col1, col2)` needs a matching unique index (not partial).122- Use `excluded.column` for referenced values and only update changed columns.123- `do nothing` is faster than `do update` if no changes required.124125### Safe schema evolution126127- Most DDL is transactional (can roll back).128- Use `create index concurrently` for non-blocking index creation; not in transactions.129- Adding `not null` with volatile defaults rewrites entire table. Non-volatile is fast.130- Drop constraints before dropping columns to avoid errors.131- Changing function signatures creates overloads. Drop old as needed.132133## Generated columns134135- Use `generated always as (...) stored` for computed, indexable columns. Use `virtual` for columns that don't need to be stored.136137## Extensions138139- **pgcrypto**: Password hashing.140- **pg_trgm**: Fuzzy search; GIN indexes for `like`/`ilike`.141- **citext**: Case-insensitive text type; prefer expression indexes unless strict constraints required.142- **btree_gin/btree_gist**: Mixed-type indexes.143- **hstore**: Key-value pairs; JSONB preferred generally.144- **timescaledb**: Time-series automation.145- **postgis**: Geospatial features.146- **pgvector**: Vector search.147- **pgaudit**: Audit logging.148149## JSONB guidance150151- Prefer `jsonb` with GIN index:152 - Containment: `jsonb_col @> '{"k":"v"}'`153 - Key existence: `jsonb_col ? 'k'`, any/all: `?|`, `?&`154 - Path containment for nested docs155- Use `jsonb_path_ops` GIN for heavy containment-only; disallows key existence ops.156- For equality/range: extract scalar to generated/expression column and index as B-tree.157- Prefer B-tree range queries over runtime casts.158- Use GIN with `@>` for JSONB arrays when needed.159- Keep core data as regular columns; reserve JSONB for optional/variable fields.160- Add constraints like `check(jsonb_typeof(config) = 'object')` for safety.161162## Examples163164### Users165166```sql167create table users (168 user_id bigint generated always as identity primary key,169 email text not null unique,170 name text not null,171 created_at timestamptz not null default now()172);173create unique index on users (lower(email));174create index on users (created_at);175```176177### Orders178179```sql180create table orders (181 order_id bigint generated always as identity primary key,182 user_id bigint not null references users(user_id),183 status text not null default 'PENDING' check (status in ('PENDING','PAID','CANCELED')),184 total numeric(10,2) not null check (total > 0),185 created_at timestamptz not null default now()186);187create index on orders (user_id);188create index on orders (created_at);189```190191### JSONB192193```sql194create table profiles (195 user_id bigint primary key references users(user_id),196 attrs jsonb not null default '{}',197 theme text generated always as (attrs->>'theme') stored198);199create index profiles_attrs_gin on profiles using gin (attrs);200```