crefft Source 2026.10.4

SQL Style Guide

crefft > Languages > SQL

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.

Based on SQL Style Guide1 with PostgreSQL focus. Conventions work across PostgreSQL2, MySQL3, and SQLite4.

Quick Reference

TaskToolCommand
LintSQLFluff5sqlfluff lint
FormatSQLFluff5sqlfluff fix
Type check--
Semantic--
Dead code--
Coverage--
Complexity--
Fuzz--
Test perf--

Linting & Formatting: SQLFluff

SQLFluff5 is the world-class SQL linter, supporting 20+ dialects.

Why SQLFluff

SQLFluff provides comprehensive linting and formatting for SQL with:

  • Support for 20+ SQL dialects (PostgreSQL, MySQL, SQLite, etc.)
  • Auto-fixing capabilities for common style violations
  • Templating support (Jinja6, dbt7) for SQL generation workflows
  • Highly configurable rules matching project-specific style guides
  • Active maintenance and community support
# Install
pip install sqlfluff==3.5.0

# Lint
sqlfluff lint .

# Fix automatically
sqlfluff fix .

# Specific dialect
sqlfluff lint --dialect postgres .

Configuration (.sqlfluff)

[sqlfluff]
dialect = postgres
templater = jinja
max_line_length = 80
indent_unit = space

[sqlfluff:indentation]
indented_joins = true
indented_using_on = true
template_blocks_indent = true

[sqlfluff:layout:type:comma]
line_position = trailing

[sqlfluff:rules:capitalisation.keywords]
capitalisation_policy = upper

[sqlfluff:rules:capitalisation.identifiers]
capitalisation_policy = lower

[sqlfluff:rules:capitalisation.functions]
capitalisation_policy = upper

[sqlfluff:rules:aliasing.table]
aliasing = explicit

[sqlfluff:rules:aliasing.column]
aliasing = explicit

Naming Conventions

General Rules

  • You MUST use snake_case for all identifiers
  • You SHOULD avoid prefixes like tbl_, vw_, sp_
  • You MUST keep names under 63 characters (PostgreSQL limit)
  • You MUST NOT use SQL reserved keywords

Tables

Table names MUST be plural nouns in snake_case.

-- Plural nouns
CREATE TABLE users (...);
CREATE TABLE order_items (...);
CREATE TABLE user_preferences (...);

-- NOT
CREATE TABLE user (...);
CREATE TABLE tbl_users (...);

Columns

Column names MUST use descriptive snake_case and SHOULD avoid ambiguous single-word names.

-- Descriptive snake_case
CREATE TABLE users (
    id BIGINT PRIMARY KEY,
    email_address TEXT NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    is_active BOOLEAN DEFAULT true,
    year_founded INTEGER  -- NOT just "year"
);

Foreign Keys

Foreign key columns MUST follow the format: referenced_table_singular_id.

-- Format: referenced_table_singular_id
CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    user_id BIGINT REFERENCES users(id),  -- NOT users_id
    shipping_address_id BIGINT REFERENCES addresses(id)
);

Indexes

Index names MUST follow the format: idx_table_column(s) with optional _unique suffix.

-- Format: idx_table_column(s)
CREATE INDEX idx_users_email ON users(email_address);
CREATE INDEX idx_orders_user_created ON orders(user_id, created_at);
CREATE UNIQUE INDEX idx_users_email_unique ON users(email_address);

Constraints

Constraint names MUST follow these formats:

  • Primary keys: table_pkey
  • Unique: table_column_key
  • Foreign keys: table_column_fkey
  • Check: table_column_check
ALTER TABLE users
    ADD CONSTRAINT users_pkey PRIMARY KEY (id),
    ADD CONSTRAINT users_email_key UNIQUE (email_address),
    ADD CONSTRAINT users_age_check CHECK (age >= 0);

ALTER TABLE orders
    ADD CONSTRAINT orders_user_id_fkey
    FOREIGN KEY (user_id) REFERENCES users(id);

Query Formatting

Queries MUST use consistent formatting with keywords in UPPERCASE and identifiers in lowercase. SQL clauses SHOULD be on separate lines for readability.

SELECT Statements

SELECT
    u.id,
    u.email_address,
    u.created_at,
    COUNT(o.id) AS order_count
FROM users AS u
LEFT JOIN orders AS o
    ON u.id = o.user_id
WHERE u.is_active = true
    AND u.created_at >= '2024-01-01'
GROUP BY u.id, u.email_address, u.created_at
HAVING COUNT(o.id) > 0
ORDER BY u.created_at DESC
LIMIT 100;

INSERT Statements

INSERT INTO users (
    email_address,
    display_name,
    is_active
)
VALUES (
    'user@example.com',
    'Example User',
    true
)
RETURNING id;

UPDATE Statements

UPDATE users
SET
    display_name = 'New Name',
    updated_at = NOW()
WHERE id = 123
    AND is_active = true
RETURNING *;

Data Types

Prefer

You SHOULD prefer these data types:

-- Text
TEXT                    -- Variable length, no artificial limit
VARCHAR(255)            -- Only when limit is meaningful

-- Numbers
BIGINT                  -- IDs, counts
INTEGER                 -- Regular integers
NUMERIC(10,2)          -- Money, precise decimals
REAL, DOUBLE PRECISION -- Scientific, approximate

-- Dates
TIMESTAMP WITH TIME ZONE  -- Always use timezone-aware (MUST for timestamps)
DATE                      -- Date only
TIME WITH TIME ZONE       -- Time only

-- Other
UUID                    -- For distributed IDs
BOOLEAN                 -- MUST use for boolean values, NOT integers
JSONB                   -- JSON data (MUST use JSONB, NOT JSON)

Avoid

You SHOULD NOT use these deprecated or problematic types:

-- CHAR(n)       -- Pads with spaces, no benefit
-- MONEY         -- PostgreSQL-specific, imprecise
-- SERIAL        -- Use IDENTITY instead
-- JSON          -- Use JSONB

Migrations

Naming

Migration files MUST follow the timestamp-based naming convention:

YYYYMMDDHHMMSS_descriptive_name.sql
20240115143022_create_users_table.sql
20240115143523_add_email_index_to_users.sql

Safe Patterns

No migration is safe on its own. Every migration MUST set a short lock_timeout before any statement that takes an ACCESS EXCLUSIVE lock, and the migration runner MUST retry a migration that the lock timeout cancels. Migrations SHOULD also set a statement_timeout sized for the table being changed. Every statement MUST be re-runnable, either inside an explicit transaction or guarded by IF NOT EXISTS.8

-- Do: bound the lock wait, bound the work, keep the statements re-runnable.
SET lock_timeout = '3s';
SET statement_timeout = '30s';

BEGIN;
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone_number TEXT;
-- A constant default is a catalogue change; existing rows are not rewritten.
ALTER TABLE users ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active';
COMMIT;
-- Don't: unbounded lock wait, and a partial failure cannot be rerun.
ALTER TABLE users ADD COLUMN phone_number TEXT;

Why Bounded Migrations

ALTER TABLE waits for its ACCESS EXCLUSIVE lock behind any transaction that already holds a conflicting lock, and every read and write that arrives while it waits queues behind it. An unbounded wait therefore stops all traffic to the table, not only the migration. A short lock_timeout turns that outage into ERROR: canceling statement due to lock timeout, which the runner retries.8 statement_timeout bounds the work once the lock is held, so a statement that turns out to rewrite a large table cannot hold the lock for minutes.

The distinction that matters for column additions is the default value. A constant default is recorded in the catalog and applied on read, so the statement is fast on any table size. A volatile default, such as clock_timestamp(), updates every row while holding the lock.9

Adding a Required Column

When the value cannot come from a constant default, you MUST split the change so that no table scan runs under an ACCESS EXCLUSIVE lock: add the column nullable, backfill in batches, then add the constraint NOT VALID and validate it separately.

-- Step 1: add the column nullable, so no row is rewritten.
SET lock_timeout = '3s';
SET statement_timeout = '30s';
ALTER TABLE users ADD COLUMN IF NOT EXISTS signup_source TEXT;

-- Step 2: backfill in bounded batches, committing each batch, and repeat
-- until the statement reports zero rows.
UPDATE users SET signup_source = 'import'
WHERE id IN (
    SELECT id FROM users WHERE signup_source IS NULL ORDER BY id LIMIT 1000
);

-- Step 3: record the constraint without scanning the table.
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_signup_source_not_null;
ALTER TABLE users ADD CONSTRAINT users_signup_source_not_null
    CHECK (signup_source IS NOT NULL) NOT VALID;

-- Step 4: validate under SHARE UPDATE EXCLUSIVE, which does not block writes.
SET statement_timeout = '10min';
ALTER TABLE users VALIDATE CONSTRAINT users_signup_source_not_null;

BEGIN;
ALTER TABLE users ALTER COLUMN signup_source SET NOT NULL;
COMMIT;

Why Staged Constraints

ALTER COLUMN ... SET NOT NULL on its own checks every row while holding the ACCESS EXCLUSIVE lock. Splitting the change moves the scan out of that lock: adding the constraint NOT VALID is a catalog change, and validating it takes only a SHARE UPDATE EXCLUSIVE lock, which permits reads and writes. PostgreSQL then uses the validated constraint to skip the scan SET NOT NULL would otherwise perform. Measured on PostgreSQL 18.6 with 3,000,000 rows: direct SET NOT NULL took 295 ms, while the staged sequence took 0.4 ms to add the constraint, 152 ms to validate it, and 0.5 ms to set NOT NULL.

Creating Indexes on Live Tables

Indexes on tables that carry production traffic MUST be created with CREATE INDEX CONCURRENTLY, and that statement MUST NOT run inside a transaction block. A concurrent build is not lock-free: it takes a SHARE UPDATE EXCLUSIVE lock, scans the table twice, waits for existing transactions to finish, and takes significantly longer than an ordinary build.10 It permits reads and writes, but blocks schema changes and any other concurrent build on the same table.

-- Do: outside any transaction, with a bounded lock wait and an unbounded
-- build, because cancelling a concurrent build leaves an invalid index.
SET lock_timeout = '3s';
SET statement_timeout = 0;
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_phone_number
    ON users (phone_number);

A failed concurrent build leaves an INVALID index that keeps its name, is ignored by queries, and still costs write overhead.10 IF NOT EXISTS matches that name, so a rerun reports success while the index remains unusable. After any failed build you MUST check the catalog and drop the invalid index before rebuilding.

SELECT c.relname AS index_name
FROM pg_index AS i
JOIN pg_class AS c ON c.oid = i.indexrelid
WHERE NOT i.indisvalid;

-- Recovery: drop the invalid index, then rerun the concurrent build.
SET lock_timeout = '3s';
SET statement_timeout = '30s';
DROP INDEX CONCURRENTLY IF EXISTS idx_users_phone_number;

An ordinary CREATE INDEX SHOULD be used where no production traffic can reach the table yet, such as a table created in the same migration: it is faster and runs inside the transaction. Concurrent builds are not supported on partitioned parents; build the index on each partition concurrently, then create the parent index non-concurrently as a metadata-only operation.10

Renaming Columns

Renaming a column is not a safe pattern. The rename takes effect for every session at commit, so any deployed code still using the old name fails immediately. You MUST expand and contract instead: add the new column, write both columns, backfill, move reads across, and drop the old column in a later migration once no deployed code refers to it.

-- Don't: every client using the old name breaks the moment this commits.
ALTER TABLE users RENAME COLUMN phone_number TO phone;

Squawk (PostgreSQL Migration Linter)

Squawk’s PyPI distribution is squawk-cli; the distribution named squawk is an unrelated project. You MUST install squawk-cli and MUST pin the version so local runs and CI apply the same rules.

# Install (the distribution is squawk-cli, the command is squawk)
pip install squawk-cli==2.64.0

# Lint migrations
squawk migrations/*.sql

Squawk11 catches unsafe migration patterns specific to PostgreSQL. It exits non-zero when a migration is unsafe, so it MUST run in CI as well as locally.

Why Squawk

Squawk provides PostgreSQL-specific migration safety analysis:

  • Detects table-locking operations that cause downtime
  • Identifies unsafe constraint additions
  • Warns about missing CONCURRENTLY on index creation
  • Prevents common PostgreSQL migration pitfalls
  • Designed specifically for zero-downtime deployments

Comments

You SHOULD add comments to tables and columns to document their purpose. Comments MUST be updated when schema changes.

-- Table comments
COMMENT ON TABLE users IS 'Registered user accounts';

-- Column comments
COMMENT ON COLUMN users.email_address IS 'Primary email, used for login';

-- Update comments when schema changes!

Pre-commit Configuration

repos:
  - repo: https://github.com/sqlfluff/sqlfluff
    rev: 3.5.0
    hooks:
      - id: sqlfluff-lint
        args: [--dialect, postgres]
      - id: sqlfluff-fix
        args: [--dialect, postgres]
  - repo: https://github.com/sbdchd/squawk
    rev: v2.64.0
    hooks:
      - id: squawk
        files: ^migrations/.*\.sql$
        additional_dependencies: ["squawk-cli@2.64.0"]

CI Pipeline

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
      - run: pip install sqlfluff==3.5.0
      - run: sqlfluff lint --dialect postgres .
      - run: pip install squawk-cli==2.64.0
      - run: squawk migrations/*.sql

Schema Anti-Patterns

These patterns cause real production problems that linters cannot catch.

Entity-Attribute-Value (EAV) Tables

You MUST NOT use EAV tables for structured data. EAV destroys type safety, prevents foreign keys, and makes queries complex.

-- BAD: EAV pattern - loses type safety, can't use foreign keys
CREATE TABLE product_attributes (
    product_id BIGINT,
    attribute_name TEXT,    -- "price", "weight", "color"
    attribute_value TEXT    -- Everything is a string!
);

-- Querying EAV is painful
SELECT
    p.name,
    MAX(CASE WHEN a.attribute_name = 'price' THEN a.attribute_value END) AS price,
    MAX(CASE WHEN a.attribute_name = 'color' THEN a.attribute_value END) AS color
FROM products AS p
LEFT JOIN product_attributes AS a ON p.id = a.product_id
GROUP BY p.id, p.name;

-- GOOD: Use proper columns with real types
CREATE TABLE products (
    id BIGINT PRIMARY KEY,
    name TEXT NOT NULL,
    price NUMERIC(10,2) NOT NULL,  -- Real type!
    weight_kg NUMERIC(8,3),
    color TEXT
);

-- Use JSONB for truly dynamic attributes
CREATE TABLE products (
    id BIGINT PRIMARY KEY,
    name TEXT NOT NULL,
    price NUMERIC(10,2) NOT NULL,
    custom_attributes JSONB DEFAULT '{}'  -- For genuinely variable data
);

Time-Partitioned Table Names

You MUST NOT encode time periods in table names. This creates maintenance burden and breaks queries when periods change.

-- BAD: Table names encode time
CREATE TABLE events_2024_01 (...);
CREATE TABLE events_2024_02 (...);
-- Now every query needs UNION ALL across all tables
-- Adding a new month requires schema changes

-- GOOD: Use database-native partitioning
CREATE TABLE events (
    id BIGINT,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL,
    event_type TEXT,
    payload JSONB
) PARTITION BY RANGE (created_at);

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');

-- Queries work on the parent table automatically
SELECT * FROM events WHERE created_at >= '2024-02-15';

Separate Value and Unit Columns

You SHOULD NOT store numeric values separately from their units. This leads to unit mismatch bugs.

-- BAD: Unit stored separately - easy to mix up
CREATE TABLE measurements (
    id BIGINT PRIMARY KEY,
    value NUMERIC,
    unit TEXT  -- "kg", "lb", "g" - which is it?
);

-- GOOD: Unit in column name, single canonical unit
CREATE TABLE measurements (
    id BIGINT PRIMARY KEY,
    weight_kg NUMERIC(10,3),  -- Always kilograms
    distance_m NUMERIC(12,2)  -- Always meters
);

-- GOOD: For multi-unit storage, use composite type or normalize on write
CREATE TABLE shipments (
    id BIGINT PRIMARY KEY,
    weight_kg NUMERIC(10,3) NOT NULL,  -- Canonical unit
    weight_display_unit TEXT DEFAULT 'kg'  -- For UI only
);

References

See Also

  • Testing Guide - Best practices for testing SQL queries and database interactions

Footnotes

  1. SQL Style Guide - https://www.sqlstyle.guide/ ↩

  2. PostgreSQL Documentation - https://www.postgresql.org/docs/ ↩

  3. MySQL Documentation - https://dev.mysql.com/doc/ ↩

  4. SQLite Documentation - https://www.sqlite.org/docs.html ↩

  5. SQLFluff - SQL Linter and Formatter - https://www.sqlfluff.com/ ↩ ↩2 ↩3

  6. Jinja - Template Engine for Python - https://jinja.palletsprojects.com/ ↩

  7. dbt - Data Build Tool - https://www.getdbt.com/ ↩

  8. Squawk - Safe Migrations - https://squawkhq.com/docs/safe_migrations ↩ ↩2

  9. PostgreSQL - Modifying Tables - https://www.postgresql.org/docs/current/ddl-alter.html ↩

  10. PostgreSQL - CREATE INDEX - https://www.postgresql.org/docs/current/sql-createindex.html ↩ ↩2 ↩3

  11. Squawk - PostgreSQL Migration Linter - https://github.com/sbdchd/squawk ↩