Postgres Patterns for Developers
Schema decisions that let a single developer move fast without painting themselves into corners.
The baseline schema
Every SaaS table gets these columns:
CREATE TABLE users (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
name TEXT,
plan TEXT NOT NULL DEFAULT 'free',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Auto-update updated_at
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
Soft deletes
Don't delete rows — mark them deleted and filter in queries.
ALTER TABLE posts ADD COLUMN deleted_at TIMESTAMPTZ;
-- "Active" posts
SELECT * FROM posts WHERE deleted_at IS NULL;
-- Partial index for performance
CREATE INDEX idx_posts_active ON posts (user_id) WHERE deleted_at IS NULL;
In Prisma:
// Always filter soft-deleted rows
const posts = await db.post.findMany({
where: { userId, deletedAt: null },
});
Multi-tenancy with RLS
Row-Level Security lets Postgres enforce tenant isolation at the database level.
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON posts
USING (user_id = current_setting('app.user_id')::TEXT);
Set the user context per-request:
await db.$executeRaw`SET LOCAL app.user_id = ${userId}`;
Useful indexes
-- Full-text search (no need for Elasticsearch at developer scale)
ALTER TABLE posts ADD COLUMN search_vector TSVECTOR
GENERATED ALWAYS AS (
to_tsvector('english', coalesce(title,'') || ' ' || coalesce(body,''))
) STORED;
CREATE INDEX idx_posts_search ON posts USING GIN (search_vector);
-- Query
SELECT * FROM posts WHERE search_vector @@ plainto_tsquery('english', 'your query');
Connection pooling
Use PgBouncer or Supabase's built-in pooler. Serverless environments open a new connection per request — without pooling you'll hit Postgres limits fast.
DATABASE_URL="postgres://...?pgbouncer=true&connection_limit=1"
What I've learned
- Use
TEXToverVARCHAR(n)— Postgres stores them the same way,TEXTis less footgun. - Always store timestamps in UTC (
TIMESTAMPTZ). - Add
created_at/updated_atto every table from day one. You'll regret not having them. - Avoid EAV patterns (key-value tables). Use JSONB for truly dynamic attributes instead.