Scope: organization
Status: enabled
Path: technologies/postgresql.md
PostgreSQL is the organization's primary relational database. This document covers naming conventions, query patterns, index strategy, and operational rules that apply to all services using Postgres.
- Tables: lowercase
snake_case, plural nouns (users,audit_events,api_keys). - Columns: lowercase
snake_case. Primary key is alwaysid. Created/updated timestamps are alwayscreated_at/updated_atwith typeTIMESTAMPTZ. - Foreign keys:
{referenced_table_singular}_id(e.g.,user_id,tenant_id). - Indexes:
idx_{table}_{columns}(e.g.,idx_users_email,idx_events_tenant_id_created_at). - Constraints:
chk_{table}_{condition},uq_{table}_{columns}.
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()Add an updated_at trigger or handle it at the ORM layer — the column must stay accurate.
- Use Alembic (Python) or Flyway (JVM) for schema migrations. Raw
psqlscripts applied manually are prohibited in production. - Every migration must be reversible. Write both
upgrade()anddowngrade(). - Never alter a column type or drop a column in the same migration that removes code using it — separate the migration from the code change by at least one deploy cycle (expand/contract pattern).
- Migrations run in CI before tests. If the migration fails, the PR cannot merge.
| Operation | Safety | Notes |
|---|---|---|
| Add nullable column | ✅ Safe | Instant |
| Add column with default (Postgres 11+) | ✅ Safe | Metadata-only |
Add index CONCURRENTLY |
✅ Safe | Runs without table lock |
| Drop unused index | ✅ Safe | No data change |
Add NOT NULL constraint |
Rewrites table; use check + validate pattern | |
| Add plain index (not concurrent) | Locks table during build | |
| Drop column | Follow expand/contract | |
| Rename column/table | ❌ Unsafe | Breaks existing queries without a view bridge |
See cwes/remediation/CWE-89.md for the full rationale. Short form: no string interpolation in queries.
Specify columns explicitly. SELECT * breaks when columns are added and may fetch data the caller never uses.
Use keyset (cursor) pagination for large, frequently-updated tables. Offset pagination degrades at high page numbers:
-- ✅ Keyset — O(log n) regardless of page depth
SELECT id, name, created_at
FROM users
WHERE created_at < :cursor_timestamp
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- ⚠️ Offset — scans all prior rows at high offsets
SELECT id, name FROM users ORDER BY created_at DESC LIMIT 20 OFFSET 10000;Always check query counts in tests for list endpoints. Use SQLAlchemy's selectinload / joinedload for related objects rather than hitting the DB inside a loop.
- Every foreign key column must have an index (Postgres does not add these automatically).
- Add composite indexes for queries that filter on multiple columns — order matters: put the most selective column first.
- Use
pg_stat_user_indexesto identify unused indexes before adding new ones. - Text search: use
tsvector+ GIN index, notLIKE '%term%'on large tables.
-- Full-text search index
ALTER TABLE knowledge_documents
ADD COLUMN body_tsv TSVECTOR
GENERATED ALWAYS AS (to_tsvector('english', coalesce(title,'') || ' ' || coalesce(body,''))) STORED;
CREATE INDEX idx_knowledge_documents_body_tsv
ON knowledge_documents USING GIN (body_tsv);- Always use a connection pool (
pgBounceror the driver's built-in pool). Direct connections from application code without pooling are prohibited on production databases. - Set
statement_timeoutandlock_timeouton application connections to prevent runaway queries from blocking migrations or other traffic:
# SQLAlchemy engine setup
engine = create_async_engine(
DATABASE_URL,
connect_args={
"server_settings": {
"statement_timeout": "30000", # 30 s
"lock_timeout": "5000", # 5 s
}
},
pool_size=10,
max_overflow=20,
)- Backups are automated. Verify restore procedures quarterly.
- Never run DDL on prod directly. All schema changes go through migrations in CI/CD.
- EXPLAIN ANALYZE any new query expected to run against tables > 100k rows before shipping it.
- Long-running transactions (> 30 s) block autovacuum and cause table bloat. Avoid transactions that span network calls.