agentleFS
suleymansuleyman/fullstack-agent-skills/skills/postgresql/SKILL.md

postgresql

Skill2 starsChanged 37 days agoMITView on GitHub

Opinionated rules for PostgreSQL and PostGIS behind an application API, covering schema design, constraints, indexing, query optimization, transactions and concurrency, cursor pagination, zero-downtime migrations with Alembic, and geospatial queries. Use when designing tables, writing or reviewing migrations, adding indexes, reading EXPLAIN output, fixing slow queries or lock contention, handling race conditions, or implementing location and radius search.

21 other projects use a version of this.

Adopted by

Most recently taken up by

Used with

Projects with it also use redis, mongodb and 1 more.

Other files people use alongside this one.

Files found in the same public projects as a version of this one, more often than they turn up anywhere else. Each project counts once, large collections count for less, and this file's own repository is left out.
  • Sorcha-PlatformSkill

    redis

    Implements Redis caching, session management, and distributed coordination for the Sorcha platform. Use when: Adding caching layers, token revocation, rate limiting, SignalR backplane, or distributed state.

    Also in 3 projects that use this

  • hoodiniSkill

    mongodb

    Work with MongoDB databases using best practices. Use when designing schemas, writing queries, building aggregation pipelines, or optimizing performance. Triggers on MongoDB, Mongoose, NoSQL, aggregation pipeline, document database, MongoDB Atlas.

    Also in 3 projects that use this

  • wrsmith108Skill

    docker

    Container-based development for isolated, reproducible environments. Use when running npm commands, installing packages, executing code, or managing project dependencies. Trigger phrases include "npm install", "run the build", "start the server", "install package", or any code execution request.

    Also in 3 projects that use this

The file

Show its rules and text

We split this skill into the rules it gives an agent

18 rules. 0 are common, 9 are distinctive, and 9 are very specific to this project.

  • 0

    Common

    Most of the 20 edited versions we read keep these.

  • 9

    Distinctive

    This author's own choices. Worth a look.

  • 9

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Indexes follow queries: Every index must name the query or constraint it serves.

    line 25
  2. Distinctive:

    Keep transactions short: Never hold one open across network calls, user think-time, or file uploads.

    line 27
  3. Distinctive:

    One owner: Only the API service's migration step changes the schema.

    line 28
  4. Very specific:

    Time: always timestamptz, never timestamp.

    line 33
  5. Very specific:

    Soft delete: only when the product requires it.

    line 43
  6. Very specific:

    Expression indexes: must match the query expression exactly (lower(email)).

    line 49
  7. Very specific:

    Use one transaction when several writes must succeed or fail together.

    line 74
  8. Very specific:

    Lost updates: use an atomic UPDATE ... SET n = n + 1, a SELECT ...

    line 75
  9. Distinctive:

    Expand, migrate, contract: Add the new structure, dual-write, backfill in batches, switch reads, and drop the old structure in a later …

    line 89
  10. Very specific:

    No table rewrites under traffic: add NOT NULL, CHECK, and FK constraints as NOT VALID, then VALIDATE them separately.

    line 90
  11. Very specific:

    Use geography(Point, 4326) for "within N meters" on global data, and geometry with a projected SRID for heavy spatial analysis in a bounded …

    line 97
  12. Distinctive:

    Never return other users' precise coordinates.

    line 101
  13. Distinctive:

    Put a pooler (PgBouncer in transaction mode) in front once replicas × pool size approaches a few hundred connections.

    line 114
  14. Very specific:

    Use materialized views (REFRESH MATERIALIZED VIEW CONCURRENTLY, which needs a unique index) or denormalized counters for expensive …

    line 115
  15. Distinctive:

    Read replicas suit analytics and admin reads.

    line 116
  16. Distinctive:

    Never copy production data into development or staging without anonymization.

    line 126
  17. Distinctive:

    Test against the same major version and extensions as production.

    line 130
  18. Distinctive:

    Check plans of critical queries on seeded data with realistic cardinality.

    line 134

“Common” is measured: kept by at least 6 in 10 of the 20 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

PostgreSQL

Design and operate the PostgreSQL database that one API service owns. The database is the last line of defense for correctness: it enforces invariants the application can't guarantee under concurrency. Migrations are treated as deployments.

When to use

  • Designing or changing tables, constraints, or indexes.
  • Writing or reviewing an Alembic migration, especially against a table that already has traffic.
  • Diagnosing a slow query, lock waits, deadlocks, connection exhaustion, or bloat.
  • Implementing pagination, counters, queues, upserts, or anything with concurrent writers.
  • Storing or querying locations with PostGIS.

Core rules

  1. Enforce invariants in the database. Uniqueness, referential integrity, non-null, value ranges, and non-overlap are UNIQUE, FOREIGN KEY, NOT NULL, CHECK, and EXCLUDE constraints. Application checks exist to produce friendly error messages. Constraints are what keep the data correct. "Select, then insert if missing" in application code is a race: use a unique constraint plus ON CONFLICT.
  2. Indexes follow queries. Every index must name the query or constraint it serves. Add indexes from real query patterns (pg_stat_statements, EXPLAIN), not by habit.
  3. Migrations are deployments. Each one is compatible with the code currently running, is safe under production load, and runs as a separate pipeline step, never on application startup.
  4. Keep transactions short. Never hold one open across network calls, user think-time, or file uploads.
  5. One owner. Only the API service's migration step changes the schema. No other service writes to its tables.

Schema design

  • Primary keys: uuid (UUIDv7 for insert locality) for anything exposed through the API. Generate it in the application, or with uuidv7() on PostgreSQL 18+. Use bigint GENERATED ALWAYS AS IDENTITY for internal tables. Never use serial in new code, and never expose sequential IDs publicly.
  • Time: always timestamptz, never timestamp. Store UTC and convert at the edges.
  • Money: numeric(12,2) or integer minor units (cents) with a currency column. Never floating point.
  • Strings: text, with a CHECK (char_length(col) <= n) where the length is a real business rule.
  • Small sets of values: text + CHECK (status IN (...)) when values may change. Native ENUM types only for truly fixed sets, because enum values can't be removed and adding them has transaction restrictions.
  • NOT NULL by default. Make a column nullable only when "unknown" or "absent" means something.
  • Foreign keys always, with a deliberately chosen ON DELETE: RESTRICT (the default choice), CASCADE for owned children, SET NULL for optional references.
  • Name every constraint through the SQLAlchemy naming convention (uq_users_email, ck_posts_body_length), so migrations stay deterministic and the API can map violations to error codes.
  • Case-insensitive uniqueness: a unique index on lower(email), or the citext type.
  • Multi-tenant data: tenant_id on every tenant-owned table, as the leading column of composite indexes and unique constraints (UNIQUE (tenant_id, slug)).
  • jsonb for genuinely schemaless data (provider payloads, user settings). Never for data you filter, join, or constrain relationally.
  • Soft delete only when the product requires it. Then every unique constraint becomes a partial index (WHERE deleted_at IS NULL) and every query must filter on it. Consider an archive table instead.

Indexing

  • Composite order: equality columns first, then the range or sort column. For WHERE author_id = $1 ORDER BY created_at DESC, id DESC, use (author_id, created_at, id). A btree scans backwards, so you need DESC in the index only when sort directions are mixed.
  • Partial indexes for hot subsets: WHERE status = 'pending', WHERE deleted_at IS NULL.
  • Expression indexes must match the query expression exactly (lower(email)).
  • INCLUDE columns enable index-only scans on hot read paths.
  • Index types: GIN for jsonb @>, arrays, full-text, and pg_trgm (ILIKE '%term%'). GiST for PostGIS, ranges, and exclusion constraints. BRIN for very large append-only tables correlated with insert order.
  • Foreign keys: index the referencing column when you filter children by parent (WHERE post_id = $1) or when parent rows are deleted or updated. Without one, each parent delete scans the whole child table, and ON DELETE CASCADE multiplies that. Don't add FK indexes blindly: skip them when the FK is never filtered and the parent is never deleted (e.g. created_by on an append-only audit log), and when the FK is already the leading column of another index.
  • Unused indexes cost writes and prevent HOT updates. Find them with pg_stat_user_indexes.idx_scan = 0 over a representative period, and check replicas too, because statistics are per node.
  • On tables with traffic, always use CREATE INDEX CONCURRENTLY. After a failed concurrent build, find and drop the INVALID index before retrying.

Queries

  • No N+1 queries. Join, or batch with WHERE id = ANY($1::uuid[]).
  • Keyset (cursor) pagination for anything that grows:
    SELECT id, body, created_at FROM posts
    WHERE author_id = $1 AND (created_at, id) < ($2, $3)
    ORDER BY created_at DESC, id DESC
    LIMIT 21;  -- page size + 1 tells you whether a next page exists
    OFFSET reads and discards every skipped row and shifts when rows are inserted. Use it only for small, bounded admin tables where jumping to page N matters.
  • No exact COUNT(*) over large tables per request. Use hasMore from LIMIT n + 1, or estimates (pg_class.reltuples).
  • Upserts: INSERT ... ON CONFLICT (cols) DO UPDATE | DO NOTHING.
  • Bulk writes: multi-row INSERT, COPY, or UPDATE ... FROM (VALUES ...). Not a loop of single statements.
  • Keep predicates sargable: created_at >= $1 AND created_at < $2, not date(created_at) = $1. Use = ANY($1) instead of huge IN (...) lists.

Transactions and concurrency

  • Use one transaction when several writes must succeed or fail together. Keep the default READ COMMITTED unless a specific anomaly requires more.
  • Lost updates: use an atomic UPDATE ... SET n = n + 1, a SELECT ... FOR UPDATE on the row, or optimistic concurrency (UPDATE ... WHERE id = $1 AND version = $2, where 0 rows means 409).
  • SERIALIZABLE for invariants spanning rows. Callers must retry on 40001 (serialization failure) and 40P01 (deadlock).
  • Job queues: SELECT ... FOR UPDATE SKIP LOCKED LIMIT n.
  • Singleton jobs (cron across replicas): pg_try_advisory_xact_lock(key).
  • No overlaps (bookings, shifts): EXCLUDE USING gist (resource_id WITH =, during WITH &&), which needs btree_gist.
  • Deadlocks: lock rows in a consistent order, such as ascending id.
  • Set statement_timeout and idle_in_transaction_session_timeout on the application role (ALTER ROLE app SET ...).

Migrations

The full playbook is in references/zero-downtime-migrations.md. The non-negotiables:

  • Run migrations as a release step (a CI job or a one-off task using the release image) before new code rolls out. Never run them on application startup. Replicas race, long locks block boot, a failure crash-loops every instance, and destructive steps run before anyone reviews them.
  • Set lock_timeout (e.g. 5s) for every migration, so a blocked DDL fails fast instead of queuing every query behind it.
  • Expand, migrate, contract. Add the new structure, dual-write, backfill in batches, switch reads, and drop the old structure in a later release, after no deployed code (and no supported mobile app version, through the API) depends on it.
  • No table rewrites under traffic: add NOT NULL, CHECK, and FK constraints as NOT VALID, then VALIDATE them separately. Build indexes CONCURRENTLY. Change types by adding a new column.
  • Review every autogenerated migration. Alembic renders a rename as drop + add (data loss), doesn't emit CONCURRENTLY, and misses some changes.

PostGIS

Details and privacy guidance are in references/postgis.md.

  • Use geography(Point, 4326) for "within N meters" on global data, and geometry with a projected SRID for heavy spatial analysis in a bounded region.
  • Longitude comes first: ST_MakePoint(lng, lat). A swapped pair is the most common geospatial bug.
  • Radius search: ST_DWithin(location, $point::geography, $meters), which uses the GiST index. Never ST_Distance(...) < $r in WHERE, which can't use the index.
  • Nearest N: ORDER BY location <-> $point LIMIT n.
  • Never return other users' precise coordinates. Snap them to a grid and bucket distances.

Debugging

  • EXPLAIN (ANALYZE, BUFFERS). Look for a Seq Scan on a large table with a selective filter, estimated vs actual rows off by 10× or more (run ANALYZE, or add CREATE STATISTICS for correlated columns), a large "Rows Removed by Filter", sorts spilling to disk, or a nested loop with a large outer side. ANALYZE executes the statement, so wrap writes in BEGIN; ... ROLLBACK;.
  • Top offenders: pg_stat_statements ordered by total_exec_time. Fix the queries that dominate total time, not the slowest one-off.
  • Locks: join pg_stat_activity with pg_blocking_pids(pid). Look for long idle in transaction sessions.
  • Index not used: type mismatch (uuid vs text parameter), a function wrapped around the column, low selectivity, a LIKE 'prefix%' without text_pattern_ops under a non-C collation, or stale statistics.
  • Bloat or vacuum lag: pg_stat_user_tables.n_dead_tup and last_autovacuum. Tune autovacuum per table for high-churn tables.
  • Query plans on a 100-row development database tell you nothing. Reproduce with production-like volume and distribution.

Performance

  • Put a pooler (PgBouncer in transaction mode) in front once replicas × pool size approaches a few hundred connections. Each Postgres connection is a process.
  • Use materialized views (REFRESH MATERIALIZED VIEW CONCURRENTLY, which needs a unique index) or denormalized counters for expensive aggregates read often.
  • Read replicas suit analytics and admin reads. Anything that needs read-your-writes stays on the primary.
  • Partition only very large time-series tables with retention needs (drop partitions instead of DELETE). It's not a default optimization.

Security

  • Separate roles: a migration role that owns the schema (DDL), and an application role with DML on its tables only. No superuser, no CREATE on the schema. Apps never connect as postgres.
  • TLS to the database (sslmode=verify-full where the provider supports it).
  • Parameterized queries only.
  • Row-level security is optional defense-in-depth for multi-tenant data (SET LOCAL app.tenant_id per transaction, which is pooler-safe only with SET LOCAL).
  • Backups: point-in-time recovery enabled and a restore drill on a schedule. A backup you've never restored doesn't count.
  • Never copy production data into development or staging without anonymization.

Testing

  • Test against the same major version and extensions as production. Never SQLite.
  • In CI: migrate an empty database to head. For reversible migrations, run downgrade one step and upgrade again.
  • Assert that constraints reject bad data (unique, check, FK, exclusion) and that the API maps each violation to the right error.
  • Rehearse risky migrations on an anonymized production-sized copy and measure lock and run times.
  • Check plans of critical queries on seeded data with realistic cardinality.

Production-readiness checklist

  • Every table has a primary key, NOT NULL by default, timestamptz, and named constraints.
  • Business invariants are constraints, not only application checks.
  • Every index maps to a query or constraint. No duplicate or redundant indexes.
  • Hot queries are EXPLAIN ANALYZEd at realistic volume. No N+1.
  • Growing lists use keyset pagination with a capped page size.
  • Migrations set lock_timeout, use CONCURRENTLY and NOT VALID on live tables, and follow expand/contract.
  • Migrations run as a release step with a dedicated DDL role, not at startup.
  • The application role is least-privilege, with statement_timeout and idle_in_transaction_session_timeout set.
  • pg_stat_statements and slow query logging (log_min_duration_statement) are on.
  • Backups use PITR, and a restore drill has been completed.
  • PostGIS: GiST index, lon/lat order verified by a test, ST_DWithin for radius queries, coarse coordinates in responses.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.