with one click
sql-conventions
// Use when: designing relational schemas, writing migrations, changing indexes, optimizing SQL queries, configuring connection pools, handling transactions, or making SQL database architecture decisions.
// Use when: designing relational schemas, writing migrations, changing indexes, optimizing SQL queries, configuring connection pools, handling transactions, or making SQL database architecture decisions.
[HINT] Download the complete skill directory including SKILL.md and all related files
| name | sql-conventions |
| description | Use when: designing relational schemas, writing migrations, changing indexes, optimizing SQL queries, configuring connection pools, handling transactions, or making SQL database architecture decisions. |
| user-invocable | false |
users, order_items)created_at, user_id)idx_{table}_{columns} for regular indexes, uq_{table}_{columns} for unique indexes{referenced_table_singular}_id (e.g., user_id). When multiple columns reference the same table, use semantic names (e.g., sender_id, receiver_id instead of user_id_1, user_id_2)Use auto-increment integer (SERIAL / BIGSERIAL / AUTO_INCREMENT) as the default primary key. It's simple, compact, and index-friendly.
When a public-facing, non-sequential identifier is needed (e.g., for URLs, external APIs, or preventing enumeration), add a separate column (external_id, public_id, etc.) — do not replace the auto-increment PK. This keeps internal joins and indexes on efficient integers while exposing a safe identifier externally.
Every table should include:
| Column | Type | Description |
|---|---|---|
id | Auto-increment integer | Primary key |
created_at | BIGINT | Unix epoch ms (13 digits) — record creation time |
updated_at | BIGINT | Unix epoch ms (13 digits) — last modification time |
Store timestamps as BIGINT (unix epoch milliseconds), not native TIMESTAMP/TIMESTAMPTZ. This eliminates the timezone conversion chain (app server → ORM → DB session → DB server) that silently introduces bugs in cross-timezone services. A number has no timezone to convert.
created_at on insert, never update itupdated_at on every update (via application code or DB trigger)to_timestamp(created_at / 1000) (PostgreSQL)deleted_at — only when soft delete is needed (see Soft Delete section)created_by / updated_by — only when audit trail is an explicit requirementNot every table needs soft delete — only add it when the feature explicitly requires recoverability or audit trail.
When needed, use a deleted_at column (BIGINT, unix epoch ms, nullable). NULL means the record exists; a value means it's deleted.
WHERE deleted_at IS NULL) so queries on live data don't scan deleted rowsWHERE deleted_at IS NULL by default. Implement this as a default scope/filter in the ORM or repository layer — don't rely on every query remembering to add itdeleted_at to allow re-creation after deletion (e.g., UNIQUE (email, deleted_at))Store enum/status values as strings (VARCHAR), not integers or DB enum types.
ALTER TYPE is painful — PostgreSQL can't remove values, and adding values had transaction restrictions before PG12. Use CHECK constraints or application-level validation instead-- Prefer this
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'suspended'))
-- Not this
status my_status_enum NOT NULL DEFAULT 'pending'
When the set of valid values changes, update the CHECK constraint in a migration — simpler and more portable than altering DB enum types.