postgres-best-practices
Postgres schema and query rules, independent of the calling language. TRIGGER — load BEFORE writing or editing a .sql file, a migration, or any schema, index, constraint or query change against Postgres: new tables, foreign keys, status columns, claim or lease queries, backfills, ON CONFLICT upserts. SKIP for MySQL, SQLite and other engines, and for code that never touches SQL.
- License 许可
- MIT
- 最近更新
- 2026-11-11
- Stars
- ★ 0
- Forks
- 0
01 前言 Front Matter
- name
- postgres-best-practices
- description
- Postgres schema and query rules, independent of the calling language. TRIGGER — load BEFORE writing or editing a .sql file, a migration, or any schema, index, constraint or query change against Postgres: new tables, foreign keys, status columns, claim or lease queries, backfills, ON CONFLICT upserts. SKIP for MySQL, SQLite and other engines, and for code that never touches SQL.
02 资源文件 5
- 参考文档evals/evals.json4.0 KB
- 参考文档references/goose.md3.7 KB
- 参考文档references/migrations.md4.3 KB
- 参考文档references/sqlc-patterns.md3.3 KB
- 参考文档references/sqlc.md4.6 KB
03 SKILL.md 正文
Postgres
Rules for schema and queries on Postgres. They are language-agnostic; the Go half lives in the
go-idiomatic skill (references/database.md, references/patterns.md).
Where to Look
| If you're working on… | Load this |
|---|---|
Go code that runs these queries (database/sql, pgx) |
go-idiomatic references/database.md |
| A closed value set mirrored as a Go type | go-idiomatic references/patterns.md § Typed Value Sets |
| Writing or changing a migration, any tool | references/migrations.md |
Converting an existing Postgres ENUM to text + CHECK |
references/migrations.md § ENUM Conversion |
| Migrations in a repo that uses goose | references/goose.md (only when the repo uses goose) |
Only if the repo has sqlc.yaml: queries, config, CI |
references/sqlc.md |
Schema
- Closed value sets are
text+ a namedCHECK, never a PostgresENUM. Adding a value to anENUMis a type migration and removing one is not supported; aCHECKis oneALTER. Never mix the two styles in one schema, and never leave a status column unchecked. An existingENUMis a conversion to schedule, not a style to extend. - Every foreign key column has an index. Postgres does not create one. Without it, deleting
a parent scans the child table, and
ON DELETE CASCADEturns that into one scan per row. timestamptzfor instants,dateonly for calendar dates. Never store times astext.jsonbfor JSON, nevertext.- Name every constraint and index, with one scheme (
<table>_<cols>_key,_fkey,_check,<table>_<cols>_idx). Names are what errors and migrations refer to. - One meaning for absence per column. NULL or
'', not both. Pick NULL unless the column isNOT NULL DEFAULT ''by design. - An append-only table gets a retention policy in the migration that creates it. Outboxes,
run logs and audit trails only grow; decide how long rows live, index the column the cleanup
filters on, and delete expired rows in batches (
DELETE ... WHERE id IN (SELECT id ... LIMIT n)) from a scheduled job. Added later, the first cleanup is one huge delete on a huge table.
Queries
- One clock per comparison.
DEFAULT now()is fine for audit columns. Any time the application compares against, or a test asserts on, is passed in as a parameter. Mixing SQLNOW()with the app'stime.Now()makes leases and expiries untestable and skewed. - Explicit column lists. No
SELECT *and noRETURNING *; adding a column must not change what callers receive. - Every list query has a hard
LIMIT. A list without one returns the whole table once the table grows. On growing tables page by keyset (WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT $3), notOFFSET, which reads and discards every skipped row. - No N+1 loops. Fetch with
= ANY($1::uuid[]), insert with multi-rowVALUESorCOPY. - Claiming work uses
FOR UPDATE SKIP LOCKEDso concurrent workers take different rows instead of queueing on one. Update the claimed row in the same statement or transaction. - Read-then-write uses
SELECT ... FOR UPDATEin a transaction. A plain read races. - Match unique violations (SQLSTATE
23505) by constraint name, not by message text or by assuming which constraint fired. This is why constraints are named. ON CONFLICTnames its target (ON CONFLICT (col)orON CONSTRAINT name). A bareON CONFLICT DO NOTHINGsilently swallows violations of constraints you did not mean.
Building SQL
- Never build SQL, or shell that carries SQL, with
fmt.Sprintfor string concatenation. Pass values as bind parameters ($1) or, for a script, as environment variables the script reads. Pass identifiers throughpgx.Identifier{"schema", "table"}.Sanitize(). Interpolating even an int starts the habit that becomes injection.
A container entrypoint is a static script that reads its value from the environment
(IDLE_TIMEOUT set via the container's Env):
//go:embed entrypoint.sh
var entrypoint string
psql -v t="$IDLE_TIMEOUT" -c "ALTER SYSTEM SET idle_in_transaction_session_timeout = :'t'"
Altering tables
- Add columns nullable or with a constant default, then backfill, then add
NOT NULL. Never rewrite a large table inside the migration that adds the column. - Backfill in batches, keyed on the primary key, committing between batches. One giant
UPDATEholds locks and bloats the table. - Create indexes on live tables with
CREATE INDEX CONCURRENTLY, outside a transaction. - Add constraints in two steps on big tables:
ADD CONSTRAINT ... NOT VALID, thenVALIDATE CONSTRAINT, which does not block writes. - Migrations are forward-only once merged. Fix mistakes with a new migration.
04 同仓库更多 Skills
vault-recall
Answer the user's conceptual or technical question using their Obsidian vault as well as general knowledge. Use when the user asks a question (not a task) whose topic appears in the vault MOC digest from session start. Not for task instructions, not mid-task, and not for writing to the vault (that is to-vault).
ingest
Ingest one external source (an article, course lesson, doc, PDF or URL) into the user's Obsidian vault: save the original to raw/, then build wiki/ pages per the vault's § Ingest. Use when the user types /ingest, or says "ingest this", "add this source to my wiki", "put this article/lesson in the vault as a wiki page". Works from any repo. Routing rule: someone else's words go to wiki/ (this skill); the user's own learnings and decisions go to notes/ (/to-vault).
to-tickets
Breaks a source into independently-grabbable GitHub tickets. For a code feature the source is the plan (`docs/plans/<feature>.md`, from /to-plan) and every new or changed symbol lands in exactly one ticket, its signatures copied into the ticket body; a non-code feature is cut from the PRD's slices. Cuts tickets for one feature per run. If a code feature has no approved plan, offers /to-plan instead of cutting from prose. In a repo that runs command-centre, also emits `[[task]]` blocks into its config; in a plain treepad repo, emits a Batch Manifest instead so the tickets materialise as stacked worktrees.
to-seams
Records an agreed-but-unlanded cross-repo interface as a seam: the file a consumer's agent gets pasted into its prompt as ground truth, plus the `[[seam]]` block command-centre reads for the retirement pointer. Use when two repos need to build against an interface neither has landed yet — "record a seam for X", "we agreed on this schema, seam it", "add a seam so Y can build against Z's new field". Halts for approval before writing either file.
to-roadmap
Prioritises settled features into a Now / Next / Later kanban board, rendered as a self-contained ./ideas/roadmap.html. Each card is a feature, not a ticket — one card later becomes several tickets via /to-prd -> /to-plan -> /to-tickets. Boards the features in ./docs/approach.md, or the "## Accepted ideas" in ./ideas/CONTEXT.md when there is no approach doc. The approach doc is settled truth: this assigns tiers and reasons about ordering, it never re-litigates what was decided.
to-prd
Turns one feature idea into a PRD built on journeys: a one-line problem, journeys (each ending in an observable state) and delivery slices that say what is usable after each. Interviews, explores the codebase, and saves to docs/prds/<feature>.md. Covers exactly one feature: given a multi-feature source, ask which one. Reads any approach doc or ideate report as priors so it doesn't re-ask what's settled, but needs none. Starts the per-feature spine (/to-prd -> /to-plan -> /to-tickets).