Skip to content
Back to skills

Postgres Patterns

ASecurity

Guides PostgreSQL query design, indexing strategy, data types, and row-level security patterns — the query/schema layer, as distinct from the migration-execution safety covered by database-migrations and the hotel_id partition-pruning rules covered by postgres-hotel-partitioning. Use when choosing an index type, designing a composite or partial index, picking a column data type, writing an UPSERT or a queue-processing query, or setting up row-level security.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 16, 2026
ai-agentspythongorubysqlgitdatabasebackendsecurityperformance

Works with

  • cursor

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned October 16, 2026

npx -y skills add shennawardana23/skillme --skill postgres-patterns --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Postgres Patterns?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Postgres Patterns
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/shennawardana23-postgres-patterns/badge)](https://www.skillsdirectory.com/skills/shennawardana23-postgres-patterns)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: postgres-patterns
description: Guides PostgreSQL query design, indexing strategy, data types, and row-level security patterns — the query/schema layer, as distinct from the migration-execution safety covered by database-migrations and the hotel_id partition-pruning rules covered by postgres-hotel-partitioning. Use when choosing an index type, designing a composite or partial index, picking a column data type, writing an UPSERT or a queue-processing query, or setting up row-level security.
license: Apache-2.0
metadata:
  version: "0.1.0"
---

# PostgreSQL Patterns

This skill is the query- and schema-design layer: how to index a table
and shape a query well. For *how to ship* a schema change safely, see
`database-migrations`. For any table partitioned by `hotel_id` (most core
tables in this organization), the partition-pruning predicate rules in
`postgres-hotel-partitioning` apply on top of everything here — every
example below assumes that predicate is already present.

## Index selection

| Query pattern | Index type | Example |
|---|---|---|
| `WHERE col = value` | B-tree (default) | `CREATE INDEX idx ON t (col)` |
| `WHERE col > value` / range | B-tree | `CREATE INDEX idx ON t (col)` |
| `WHERE a = x AND b > y` | Composite, equality columns first | `CREATE INDEX idx ON t (a, b)` |
| `WHERE jsonb_col @> '{}'` | GIN | `CREATE INDEX idx ON t USING gin (jsonb_col)` |
| Full-text `tsv @@ query` | GIN | `CREATE INDEX idx ON t USING gin (tsv)` |
| Append-only time-series ranges | BRIN | `CREATE INDEX idx ON t USING brin (created_at)` |

**Composite index column order matters**: put equality-tested columns
first, range-tested columns last, so the index can seek on the equality
prefix and then scan the range within it:

```sql
-- Serves: WHERE status = 'pending' AND created_at > '2025-01-01'
CREATE INDEX idx_jobs_status_created ON jobs (status, created_at);
```

**Covering index** avoids a heap lookup entirely when the query only
needs indexed + included columns:

```sql
CREATE INDEX idx_users_email ON users (email) INCLUDE (name, created_at);
-- SELECT email, name, created_at FROM users WHERE email = $1 -- index-only scan
```

**Partial index** shrinks the index to the subset that's actually queried
frequently:

```sql
CREATE INDEX idx_users_active ON users (email) WHERE deleted_at IS NULL;
```

An unindexed foreign key is a common, silent bottleneck (every delete on
the parent table triggers a sequential scan of the child to check for
references). Find them:

```sql
SELECT conrelid::regclass AS table_name, a.attname AS column_name
FROM pg_constraint c
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
WHERE c.contype = 'f'
  AND NOT EXISTS (
    SELECT 1 FROM pg_index i
    WHERE i.indrelid = c.conrelid AND a.attnum = ANY(i.indkey)
  );
```

Always confirm an index is actually used with `EXPLAIN (ANALYZE)` before
and after adding it — a planner that has a better option (or a table too
small to bother) will ignore an unused index while it still costs every
write a maintenance overhead.

## Data types

| Use case | Prefer | Avoid | Why |
|---|---|---|---|
| Primary keys | `bigint GENERATED ALWAYS AS IDENTITY` | `int`, random `uuid` as PK | `int` overflows at ~2.1B rows; random UUIDs fragment the B-tree and defeat locality |
| Strings | `text` | `varchar(n)` | `varchar(n)` buys no performance in Postgres, only a constraint-check cost; use a `CHECK` constraint if you truly need a length cap |
| Timestamps | `timestamptz` | `timestamp` (no zone) | `timestamp` silently drops zone info; every ambiguity it causes is a production bug about which offset "midnight" meant |
| Money / rates | `numeric(12,2)` | `float`, `double precision` | float arithmetic on currency accumulates rounding error |
| Flags | `boolean` | `varchar('Y'/'N')`, `int` | boolean is self-documenting and enables partial indexes cleanly |
| Flexible/variable attributes | `jsonb` | `json` | `jsonb` is stored decomposed and binary-indexable (GIN); `json` is stored as text and re-parsed on every access |

## Query patterns

**UPSERT** — insert-or-update in one round trip, avoiding a
check-then-act race:

```sql
INSERT INTO settings (user_id, key, value)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, key) DO UPDATE SET value = EXCLUDED.value;
```

**Cursor (keyset) pagination** — `O(1)` regardless of page depth, unlike
`OFFSET` which must scan and discard every preceding row:

```sql
-- BAD: OFFSET cost grows linearly with page number
SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 10000;

-- GOOD: keyset pagination, constant cost regardless of depth
SELECT * FROM products WHERE id > $last_seen_id ORDER BY id LIMIT 20;
```

**Queue processing** — `SKIP LOCKED` lets multiple workers pull from the
same job table concurrently without blocking on each other's row locks:

```sql
UPDATE jobs SET status = 'processing', locked_at = now()
WHERE id = (
    SELECT id FROM jobs
    WHERE status = 'pending'
    ORDER BY created_at
    LIMIT 1
    FOR UPDATE SKIP LOCKED
)
RETURNING *;
```

Without `SKIP LOCKED`, a second worker's `SELECT ... FOR UPDATE` blocks
on the first worker's lock instead of moving on to the next unlocked row
— turning concurrent workers into a serial queue.

## Row-level security

RLS enforces a filter at the database layer that no application bug can
bypass — a defense-in-depth complement to (never a replacement for)
application-level authorization checks and `hotel_id` predicates:

```sql
ALTER TABLE reservations ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON reservations
    USING (hotel_id = current_setting('app.current_hotel_id')::int);
```

**Wrap the session-context call in a scalar subquery-safe form** —
`current_setting(...)` re-evaluates per row unless the planner can prove
it's stable; on Postgres 15+ wrapping it as `(SELECT current_setting(...))`
lets the planner cache it once per statement instead of once per row:

```sql
CREATE POLICY tenant_isolation ON reservations
    USING (hotel_id = (SELECT current_setting('app.current_hotel_id'))::int);
```

Set the session variable per connection/request, scoped with
`set_config(..., true)` (`true` = local to the current transaction) so
one connection-pooled request can never leak its tenant context into the
next request reusing that connection:

```sql
SELECT set_config('app.current_hotel_id', $1, true);
```

## Anti-pattern detection queries

```sql
-- Slow queries (requires pg_stat_statements extension)
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
WHERE mean_exec_time > 100
ORDER BY mean_exec_time DESC;

-- Table bloat / vacuum falling behind
SELECT relname, n_dead_tup, last_vacuum, last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC;
```

## Gotchas

- `CREATE INDEX` (without `CONCURRENTLY`) on an existing table blocks
  writes for the build's duration — that's a migration-safety concern,
  covered in `database-migrations`, not a query-design one, but it bites
  people who copy an index definition from here straight into a migration
  against a live table.
- A composite index `(a, b)` serves `WHERE a = x` and `WHERE a = x AND b
  = y`, but not `WHERE b = y` alone — the leading column must be present
  in the predicate for the index to be used at all.
- `numeric` without a precision/scale (bare `numeric`) accepts unbounded
  digits and defeats the point of a fixed-point money type — always
  specify `numeric(precision, scale)`.
- `SKIP LOCKED` skips rows locked by *any* transaction, including ones
  that will eventually roll back — a worker can legitimately claim a job
  another worker's failed transaction "held," which is correct behavior,
  not a bug, but surprises people expecting strict FIFO ordering.
- RLS policies are bypassed entirely for the table owner and any role
  with `BYPASSRLS` — including most default admin/migration roles. Verify
  the actual application connects as a role without that attribute, or
  the policy is decorative.

## Real-world grounding

`SELECT ... FOR UPDATE SKIP LOCKED` was added in PostgreSQL 9.5 (2016)
specifically to make Postgres viable as a job queue backend without a
dedicated message broker — it's the same mechanism referenced in
`backend-patterns` for durable background job processing, and is widely
used in production queue implementations (e.g. Ruby's `good_job`,
Python's `procrastinate`) as an alternative to Redis-backed queues when a
team wants queue and application data in the same transactional store.

## Verification

- [ ] Every index choice matches the actual query predicate shape (equality vs range, composite column order)
- [ ] Foreign keys have a supporting index unless proven unnecessary
- [ ] Money uses `numeric(p,s)`; timestamps use `timestamptz`; flexible attributes use `jsonb`
- [ ] List endpoints paginate by cursor/keyset, not `OFFSET`, once the table is large
- [ ] Queue-style concurrent workers use `FOR UPDATE SKIP LOCKED`
- [ ] RLS policies (if used) wrap session-context calls for planner caching, and the app role does not have `BYPASSRLS`
- [ ] `EXPLAIN (ANALYZE)` confirms an added index is actually chosen by the planner

Files in this skill

  • SKILL.md9 KB
  • evals/evals.json2.6 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.