agentsclimarketplace

Postgresql

Skill stonegiantstudio/skills/skills/postgresql

PostgreSQL database design with proper types, naming conventions, indexing strategies, and JSONB patterns. Use when designing schemas, writing DDL, or optimizing PostgreSQL databases.From its SKILL.md

Install
npx -y skills add stonegiantstudio/skills --skill postgresql

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 2 stars2 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

16.6 KB, as published. Nobody here has run it

PostgreSQL Database Design

Database design guidance specific to PostgreSQL.

CRITICAL: Verify Before Writing SQL

NEVER guess object names. Before writing any DDL or DML:

-- List all tables
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
ORDER BY table_name;

-- Search for tables by pattern
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
  AND table_name LIKE '%user%';

-- Get columns for a table
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'users'
ORDER BY ordinal_position;

-- Get foreign keys for a table
SELECT
  kcu.constraint_name,
  kcu.column_name,
  ccu.table_name AS foreign_table,
  ccu.column_name AS foreign_column
FROM information_schema.key_column_usage kcu
JOIN information_schema.constraint_column_usage ccu
  ON kcu.constraint_name = ccu.constraint_name
JOIN information_schema.table_constraints tc
  ON kcu.constraint_name = tc.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
  AND kcu.table_name = 'posts';

-- Get indexes for a table
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'users';

-- Get constraints for a table
SELECT conname, contype, pg_get_constraintdef(oid)
FROM pg_constraint
WHERE conrelid = 'users'::regclass;

PostgreSQL Strengths

What makes PostgreSQL unique among relational databases:

FeatureDescription
JSONBBinary JSON with indexing, containment operators, and path queries
Native UUIDFirst-class UUID type with gen_random_uuid()
Native BOOLEANTrue boolean type (not 0/1 integers)
ArraysNative array types: TEXT[], INTEGER[], etc.
TIMESTAMPTZTimezone-aware timestamps (stores UTC, converts on display)
Partial IndexesIndex only rows matching a WHERE clause
ENUM TypesType-safe enumerations at the database level
Full ALTER TABLEAdd/drop/modify columns and constraints on existing tables
MVCCMulti-Version Concurrency Control for concurrent writes without locking
Generated ColumnsComputed columns stored or virtual
Row Level SecurityFine-grained access control at the row level
Table PartitioningNative range, list, and hash partitioning

Data Types

Preferred Types

Use CaseTypeNotes
Text (any length)TEXTNo performance difference vs VARCHAR in PostgreSQL
Text (with limit)VARCHAR(n)Only when you need to enforce max length
TimestampsTIMESTAMPTZAlways use this, never TIMESTAMP
BooleanBOOLEANNative true/false
IntegerINTEGER-2B to +2B
Large integerBIGINTFor IDs, counts that may exceed 2B
Money/currencyNUMERIC(precision, scale)Never use FLOAT for money
JSON dataJSONBAlways use this, never JSON
Unique identifierUUIDUse with gen_random_uuid()
ArraysTEXT[], INTEGER[]Native array support

Primary Keys

-- UUID (recommended for most tables)
id UUID PRIMARY KEY DEFAULT gen_random_uuid()

-- Auto-increment (modern syntax)
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY

-- NEVER use SERIAL (deprecated)
-- id SERIAL PRIMARY KEY  -- DON'T DO THIS

UUID benefits:

  • No sequential ID enumeration attacks
  • Safe for distributed systems
  • Can generate client-side

BIGINT IDENTITY benefits:

  • Smaller storage (8 bytes vs 16)
  • Better index locality for time-ordered data
  • Simpler debugging

Timestamps

-- ALWAYS use TIMESTAMPTZ
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
deleted_at TIMESTAMPTZ  -- nullable for soft deletes

-- NEVER use TIMESTAMP (loses timezone info)
-- created_at TIMESTAMP  -- DON'T DO THIS

Naming Conventions

Based on Simon Holywell's SQL Style Guide.

Tables

  • Plural, snake_case
  • Describes the collection of entities
users
blog_posts
order_line_items

-- Junction tables: both entity names (alphabetical or logical order)
user_roles
post_tags

Columns

  • Singular, snake_case
  • No table name prefix (avoid user_name in users table)
  • Descriptive and unambiguous
-- Primary key
id

-- Foreign keys: singular_entity_id
user_id
organization_id

-- Booleans: is_, has_, can_, should_
is_active
has_verified_email
can_publish
should_notify

-- Timestamps: _at suffix
created_at
updated_at
published_at
expires_at

-- Counts: _count suffix
view_count
comment_count

-- Status/state: _status suffix
order_status
payment_status

Indexes

-- Pattern: {table}_{columns}_idx
users_email_idx
posts_user_id_idx
orders_customer_id_status_idx

-- Unique indexes
users_email_unique_idx

-- Partial indexes (include condition hint)
posts_published_at_active_idx

Constraints

-- Primary key: {table}_pkey
users_pkey

-- Foreign key: {table}_{column}_fkey
posts_user_id_fkey

-- Unique: {table}_{columns}_key
users_email_key

-- Check: {table}_{column}_check or {table}_{description}_check
orders_quantity_check
orders_dates_valid_check

-- Exclusion: {table}_{description}_excl
reservations_no_overlap_excl

Standard Table Template

-- Create ENUM type if needed
CREATE TYPE post_status AS ENUM ('draft', 'published', 'archived');

CREATE TABLE posts (
    -- Primary key
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

    -- Foreign keys (always indexed separately)
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    category_id UUID REFERENCES categories(id) ON DELETE SET NULL,

    -- Business columns
    title TEXT NOT NULL,
    slug TEXT NOT NULL,
    content TEXT,

    -- Enum column
    status post_status NOT NULL DEFAULT 'draft',

    -- Boolean flags
    is_featured BOOLEAN NOT NULL DEFAULT false,

    -- JSONB for flexible data
    metadata JSONB NOT NULL DEFAULT '{}',

    -- Timestamps
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    published_at TIMESTAMPTZ,

    -- Named constraints
    CONSTRAINT posts_title_check CHECK (char_length(title) >= 1),
    CONSTRAINT posts_slug_key UNIQUE (slug)
);

-- Always index foreign keys
CREATE INDEX posts_user_id_idx ON posts(user_id);
CREATE INDEX posts_category_id_idx ON posts(category_id);

-- Index commonly filtered columns
CREATE INDEX posts_status_idx ON posts(status);
CREATE INDEX posts_created_at_idx ON posts(created_at DESC);

-- Partial index for active records only
CREATE INDEX posts_published_at_active_idx ON posts(published_at)
    WHERE status = 'published';

-- GIN index for JSONB queries
CREATE INDEX posts_metadata_idx ON posts USING GIN (metadata);

Indexing Strategies

Index Types

TypeUse CaseExample
B-tree (default)Equality, range, sortingCREATE INDEX ... ON table(column)
GINJSONB, arrays, full-textCREATE INDEX ... USING GIN (jsonb_col)
GiSTGeometric, range types, full-textCREATE INDEX ... USING GiST (geo_col)
HashEquality only (rarely better than B-tree)CREATE INDEX ... USING HASH (col)
BRINVery large tables with natural orderingCREATE INDEX ... USING BRIN (created_at)

B-tree Index Examples

-- Single column
CREATE INDEX users_email_idx ON users(email);

-- Composite (order matters for queries)
CREATE INDEX orders_customer_status_idx ON orders(customer_id, status);

-- Descending (for ORDER BY col DESC)
CREATE INDEX posts_created_at_idx ON posts(created_at DESC);

-- Unique
CREATE UNIQUE INDEX users_email_unique_idx ON users(email);

Partial Indexes

Index only rows matching a condition - smaller and faster:

-- Only index active users
CREATE INDEX users_email_active_idx ON users(email)
    WHERE is_active = true;

-- Only index published posts
CREATE INDEX posts_published_idx ON posts(published_at)
    WHERE status = 'published';

-- Only index non-null values
CREATE INDEX orders_shipped_at_idx ON orders(shipped_at)
    WHERE shipped_at IS NOT NULL;

Expression Indexes

Index computed values:

-- Case-insensitive email lookup
CREATE INDEX users_email_lower_idx ON users(LOWER(email));

-- Extract year from timestamp
CREATE INDEX posts_year_idx ON posts(EXTRACT(YEAR FROM created_at));

-- JSONB field extraction
CREATE INDEX posts_author_idx ON posts((metadata->>'author'));

GIN Indexes for JSONB

-- Index entire JSONB document (supports @>, ?, ?&, ?|)
CREATE INDEX posts_metadata_idx ON posts USING GIN (metadata);

-- Index specific JSONB path (smaller, faster for specific queries)
CREATE INDEX posts_tags_idx ON posts USING GIN ((metadata->'tags'));

What to Index

Always index:

  • Foreign key columns (PostgreSQL doesn't auto-index these!)
  • Columns in WHERE clauses
  • Columns in JOIN conditions
  • Columns in ORDER BY (consider DESC if needed)

Consider indexing:

  • Columns with high selectivity (many unique values)
  • JSONB columns you query with @>, ?, etc.

Avoid indexing:

  • Low-selectivity columns (boolean with 50/50 distribution)
  • Frequently updated columns (index maintenance overhead)
  • Small tables (sequential scan may be faster)

JSONB Patterns

When to Use JSONB vs Normalized Tables

Use JSONB for:

  • Truly dynamic/schemaless data
  • Configuration/settings
  • Metadata/tags that vary per record
  • Third-party API responses you need to store
  • Audit logs with varying payloads

Use normalized tables for:

  • Data you query/filter frequently
  • Data with relationships to other tables
  • Data that needs referential integrity
  • Reports/analytics

JSONB Operators

-- Get JSON object field (returns JSONB)
SELECT metadata->'author' FROM posts;

-- Get JSON field as text (returns TEXT)
SELECT metadata->>'author' FROM posts;

-- Get nested field
SELECT metadata->'author'->>'name' FROM posts;
SELECT metadata#>>'{author,name}' FROM posts;  -- path syntax

-- Check if key exists
SELECT * FROM posts WHERE metadata ? 'featured';

-- Check if any key exists
SELECT * FROM posts WHERE metadata ?| array['featured', 'promoted'];

-- Check if all keys exist
SELECT * FROM posts WHERE metadata ?& array['author', 'tags'];

-- Containment (JSONB contains this structure)
SELECT * FROM posts WHERE metadata @> '{"status": "featured"}';

-- Contained by (this structure contains JSONB)
SELECT * FROM posts WHERE '{"a":1, "b":2}' <@ metadata;

JSONB Querying Examples

-- Filter by JSON field value
SELECT * FROM posts
WHERE metadata->>'author' = 'John';

-- Filter by nested JSON value
SELECT * FROM posts
WHERE metadata->'settings'->>'theme' = 'dark';

-- Filter by JSON array containment
SELECT * FROM posts
WHERE metadata->'tags' @> '["featured"]';

-- Check if array contains value
SELECT * FROM posts
WHERE metadata->'tags' ? 'featured';

-- Cast JSON value to proper type for comparison
SELECT * FROM posts
WHERE (metadata->>'view_count')::int > 100;

JSONB Modification

-- Set/update a key (jsonb_set)
UPDATE posts
SET metadata = jsonb_set(metadata, '{view_count}', '100')
WHERE id = '...';

-- Merge objects (|| operator)
UPDATE posts
SET metadata = metadata || '{"featured": true, "priority": 1}'
WHERE id = '...';

-- Remove a key (- operator)
UPDATE posts
SET metadata = metadata - 'deprecated_field'
WHERE id = '...';

-- Remove nested key
UPDATE posts
SET metadata = metadata #- '{author,temp_data}'
WHERE id = '...';

-- Append to array
UPDATE posts
SET metadata = jsonb_set(
    metadata,
    '{tags}',
    (metadata->'tags') || '"new-tag"'
)
WHERE id = '...';

ENUM Types

Creating and Using ENUMs

-- Create ENUM type
CREATE TYPE post_status AS ENUM ('draft', 'published', 'archived');

-- Use in table
CREATE TABLE posts (
    status post_status NOT NULL DEFAULT 'draft'
);

-- Insert with ENUM
INSERT INTO posts (status) VALUES ('published');

-- Query with ENUM
SELECT * FROM posts WHERE status = 'published';

Modifying ENUMs

-- Add new value (PostgreSQL 9.1+)
ALTER TYPE post_status ADD VALUE 'scheduled';

-- Add value at specific position
ALTER TYPE post_status ADD VALUE 'scheduled' AFTER 'draft';
ALTER TYPE post_status ADD VALUE 'pending' BEFORE 'published';

-- Rename value (PostgreSQL 10+)
ALTER TYPE post_status RENAME VALUE 'draft' TO 'unpublished';

-- NOTE: You CANNOT remove or reorder ENUM values easily
-- For that, you need to recreate the type

ENUM vs CHECK Constraint

-- ENUM: Type-safe, shows in schema, harder to modify
status post_status NOT NULL DEFAULT 'draft'

-- CHECK: More flexible, easier to modify, less discoverable
status TEXT NOT NULL DEFAULT 'draft'
    CHECK (status IN ('draft', 'published', 'archived'))

Advanced Features (Overview)

Row Level Security (RLS)

Control row-level access based on user context:

-- Enable RLS on table
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

-- Policy: users can only see their own posts
CREATE POLICY posts_user_isolation ON posts
    FOR ALL
    USING (user_id = current_setting('app.current_user_id')::uuid);

-- Policy: published posts visible to all
CREATE POLICY posts_public_read ON posts
    FOR SELECT
    USING (status = 'published');

Table Partitioning

Split large tables for better performance:

-- Range partitioning by date
CREATE TABLE events (
    id UUID DEFAULT gen_random_uuid(),
    created_at TIMESTAMPTZ NOT NULL,
    data JSONB
) PARTITION BY RANGE (created_at);

-- Create partitions
CREATE TABLE events_2024_q1 PARTITION OF events
    FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');

CREATE TABLE events_2024_q2 PARTITION OF events
    FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');

Generated Columns

Computed columns stored or virtual:

-- Stored generated column (computed on write)
CREATE TABLE products (
    price NUMERIC(10,2) NOT NULL,
    quantity INTEGER NOT NULL,
    total NUMERIC(10,2) GENERATED ALWAYS AS (price * quantity) STORED
);

-- Use in expressions
ALTER TABLE users
ADD COLUMN full_name TEXT GENERATED ALWAYS AS (first_name || ' ' || last_name) STORED;

Triggers for updated_at

Auto-update timestamp on row changes:

-- Create trigger function (once per database)
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Create trigger for each table
CREATE TRIGGER posts_updated_at
    BEFORE UPDATE ON posts
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at();

Anti-Patterns

Anti-PatternProblemSolution
TIMESTAMP instead of TIMESTAMPTZLoses timezone, causes bugsAlways use TIMESTAMPTZ
JSON instead of JSONBCan't index, slower queries, no operatorsAlways use JSONB
SERIAL instead of IDENTITYDeprecated, permission issuesUse GENERATED ALWAYS AS IDENTITY
VARCHAR(255) everywhereCargo cult from MySQLUse TEXT or appropriate length
Missing FK indexesSlow JOINs and CASCADE deletesAlways index foreign key columns
FLOAT/DOUBLE for moneyPrecision errorsUse NUMERIC(precision, scale)
Unnamed constraintsHard to modify/referenceAlways name constraints
SELECT * in productionSchema changes break codeList columns explicitly
No NOT NULL on required fieldsData integrity issuesDefault to NOT NULL, allow NULL explicitly
Soft deletes without partial indexQueries scan deleted rowsAdd partial index WHERE deleted_at IS NULL

Quick Reference

TABLES:      plural, snake_case           users, blog_posts, order_items
COLUMNS:     singular, snake_case         user_id, created_at, is_active
PRIMARY KEY: id (UUID or BIGINT)          id UUID DEFAULT gen_random_uuid()
FOREIGN KEY: singular_entity_id           user_id UUID REFERENCES users(id)
BOOLEAN:     is_/has_/can_ prefix         is_active BOOLEAN DEFAULT false
TIMESTAMP:   _at suffix + TIMESTAMPTZ     created_at TIMESTAMPTZ DEFAULT NOW()
ENUM:        CREATE TYPE + use            status post_status DEFAULT 'draft'
JSON:        JSONB (never JSON)           metadata JSONB DEFAULT '{}'
INDEX:       {table}_{columns}_idx        posts_user_id_idx
CONSTRAINT:  {table}_{cols}_{type}        posts_user_id_fkey, orders_qty_check

Keep looking

Skills are one crate of 627,801. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.