Best practices for optimizing PostgreSQL queries in a FastAPI + SQLAlchemy application.
- General Principles
- Indexing
- Query Design
- SQLAlchemy Best Practices
- Pagination
- Avoid N+1 Queries
- Batch Operations
- Transactions
- Query Analysis
- PostgreSQL Optimization
- Connection Pool
- Caching
- Soft Delete Optimization
- Full Text Search
- Monitoring
- Performance Checklist
❌ Bad
select(User)✅ Good
select(
User.id,
User.email,
)or
select(User.id, User.email)Avoid loading unnecessary columns.
Never write
SELECT *
FROM users;Prefer
SELECT
id,
email,
fullname
FROM users;Good
SELECT *
FROM users
WHERE deleted_at IS NULL;Avoid filtering in Python.
PRIMARY KEY(id)Already indexed.
Always create indexes.
CREATE INDEX idx_user_role_user
ON user_roles(user_id);Example
email
username
status
deleted_at
created_atInstead of
WHERE organization_id = ?
AND deleted_at IS NULLUse
CREATE INDEX idx_org_deleted
ON users(
organization_id,
deleted_at
);Excellent for soft delete.
CREATE INDEX idx_users_active
ON users(email)
WHERE deleted_at IS NULL;Instead of
SELECT COUNT(*)Use
SELECT EXISTS(
SELECT 1
);SQLAlchemy
from sqlalchemy import existsAlways limit results.
LIMIT 50Never fetch millions of rows.
Good
ORDER BY created_at DESCwith
INDEX(created_at)Prefer
stmt = select(User)Avoid legacy Query API.
stmt = select(
User.id,
User.email,
)Good
users = await session.scalars(stmt)instead of
await session.execute(stmt)when selecting ORM models.
Use
result.scalar_one_or_none()instead of
all()when expecting a single record.
Avoid
OFFSET 100000Prefer Cursor Pagination.
Example
WHERE id > ?
ORDER BY id
LIMIT 50Bad
for user in users:
print(user.roles)Good
select(User).options(
selectinload(User.roles)
)or
joinedload()depending on the use case.
Instead of
for user in users:
session.add(user)Use
session.add_all(users)Bulk Update
update(User)Bulk Delete
delete(User)Keep transactions short.
Good
Begin
Update
Commit
Avoid
Begin
HTTP Request
Redis
Email
Commit
Use
EXPLAIN ANALYZEExample
EXPLAIN ANALYZE
SELECT *
FROM users
WHERE email='john@test.com';Look for
- Seq Scan
- Bitmap Heap Scan
- Index Scan
Prefer
Index Scan
Vacuum
VACUUM ANALYZE;Auto Vacuum should be enabled.
Update Statistics
ANALYZE users;Avoid Huge JSON Columns
Store large blobs separately.
Use UUID
Prefer
UUID v7
or
UUID v4
instead of sequential integers when appropriate.
Recommended
create_async_engine(
DATABASE_URL,
pool_size=20,
max_overflow=40,
pool_pre_ping=True,
pool_recycle=1800,
)Do not create an engine per request.
Cache
- User Profile
- Roles
- Permissions
- Settings
Use Redis.
Avoid caching mutable transactional data unless invalidation is handled.
Always filter
deleted_at IS NULLCreate index
CREATE INDEX idx_deleted
ON users(deleted_at);Better
CREATE INDEX idx_active_users
ON users(email)
WHERE deleted_at IS NULL;Instead of
LIKE '%john%'Use
GIN Indexand
tsvectorExample
CREATE INDEX idx_users_search
ON users
USING gin(search_vector);Enable
pg_stat_statementsUseful query
SELECT
query,
calls,
total_exec_time,
mean_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC;Check Slow Queries
log_min_duration_statement = 500- No SELECT *
- Uses LIMIT
- Uses indexes
- No unnecessary ORDER BY
- No unnecessary DISTINCT
- Uses EXISTS instead of COUNT where appropriate
- Uses AsyncSession
- Uses select()
- Uses scalars()
- Uses selectinload()/joinedload()
- Avoids N+1 queries
- Loads only required columns
- EXPLAIN ANALYZE checked
- Indexes created
- Partial indexes for soft delete
- Foreign key indexes
- Composite indexes
- VACUUM enabled
- ANALYZE updated
- Cursor pagination
- Request caching
- Rate limiting
- Query timeout
- Connection pooling
- pg_stat_statements enabled
- Slow query logging
- Metrics (Prometheus/Grafana)
- Redis cache
- Read replicas (if needed)