Overview
These rules flag SQL patterns that can cause performance problems, lock contention, or inefficient query execution. They help you write faster, more scalable queries. Rules covered:for-update-no-skip: FlagsFOR UPDATEwithoutSKIP LOCKEDorNOWAITdistinct-on-order: FlagsDISTINCT ONwithout matchingORDER BYlike-starts-with-wildcard: FlagsLIKEpatterns starting with%offset-without-limit: FlagsOFFSETwithoutLIMIT
for-update-no-skip
When This Rule Triggers
This rule triggers when:FOR UPDATEis used withoutSKIP LOCKEDorNOWAITFOR NO KEY UPDATEis used withoutSKIP LOCKEDorNOWAIT
FOR UPDATE SKIP LOCKEDis usedFOR UPDATE NOWAITis usedFOR SHAREorFOR KEY SHAREis used (non-exclusive locks)- No locking clause is present
Why It Matters
Lock Contention
By default,FOR UPDATE blocks if another transaction holds a lock on any of the rows:
- Slow response times: Users wait for locks to be released
- Deadlocks: Two transactions wait for each other’s locks
- Cascade delays: One slow transaction blocks many others
- Timeout errors: Connections time out waiting for locks
Better Alternatives
Use SKIP LOCKED for Queue Processing
When processing work items from a queue, useSKIP LOCKED to skip locked rows:
- Workers never wait for each other
- High concurrency without lock contention
- Each worker processes different items
Use NOWAIT for Fail-Fast Behavior
When you can’t wait for a lock, useNOWAIT to get an immediate error:
- Immediate feedback instead of hanging
- Application can retry or show error to user
- Prevents cascade blocking
Examples
Problematic: FOR UPDATE without SKIP LOCKED
Problematic: FOR UPDATE without SKIP LOCKED
Good: FOR UPDATE SKIP LOCKED
Good: FOR UPDATE SKIP LOCKED
Good: FOR UPDATE NOWAIT
Good: FOR UPDATE NOWAIT
Allowed: FOR SHARE (non-exclusive)
Allowed: FOR SHARE (non-exclusive)
distinct-on-order
When This Rule Triggers
This rule triggers when:DISTINCT ON (...)is used without anyORDER BYDISTINCT ON (...)is used withORDER BYbut the leading columns don’t match
- Plain
DISTINCT(noON) is used DISTINCT ONhas a matchingORDER BYthat starts with the same columns
Why It Matters
Non-Deterministic Row Selection
DISTINCT ON (col) keeps the “first” row for each distinct value of col. But without ORDER BY, “first” is arbitrary:
- Non-reproducible results: Different answers each time
- Flaky tests: Tests pass/fail randomly
- User confusion: Data changes unpredictably
- Hidden bugs: Incorrect assumptions about which row is selected
ORDER BY Must Match DISTINCT ON
For deterministic results,ORDER BY must start with the DISTINCT ON columns:
ORDER BY doesn’t match, results are still non-deterministic:
Examples
Problematic: DISTINCT ON without ORDER BY
Problematic: DISTINCT ON without ORDER BY
Problematic: DISTINCT ON with non-matching ORDER BY
Problematic: DISTINCT ON with non-matching ORDER BY
dept_id. Results are non-deterministic.Good: DISTINCT ON with matching ORDER BY
Good: DISTINCT ON with matching ORDER BY
Allowed: Plain DISTINCT
Allowed: Plain DISTINCT
DISTINCT just removes duplicates. ORDER BY isn’t required.like-starts-with-wildcard
When This Rule Triggers
This rule triggers when:LIKEpattern starts with%:LIKE '%foo'orLIKE '%foo%'ILIKEpattern starts with%:ILIKE '%foo'orILIKE '%foo%'
- Pattern has
%only at the end:LIKE 'foo%' - Pattern has no wildcards:
LIKE 'foo' - Other comparison operators are used
Why It Matters
Index Can’t Be Used
When aLIKE pattern starts with %, PostgreSQL cannot use a B-tree index:
Performance Impact
Without an index, the query must:- Scan every row in the table (sequential scan)
- Check every row against the pattern
- Scale linearly with table size (O(n))
Better Alternatives
Use Suffix Match When Possible
If you’re searching for prefixes, put the wildcard at the end:Use Full-Text Search for Complex Patterns
For searching within text, use PostgreSQL’s full-text search:Use Trigram Indexes for Substring Search
For substring searches, use thepg_trgm extension with GIN or GiST indexes:
Examples
Problematic: LIKE with leading %
Problematic: LIKE with leading %
Problematic: ILIKE with leading %
Problematic: ILIKE with leading %
Problematic: LIKE with leading and trailing %
Problematic: LIKE with leading and trailing %
% prevents index usage, even though trailing % is present.Good: LIKE with trailing % only
Good: LIKE with trailing % only
Good: Exact LIKE
Good: Exact LIKE
= 'test' would be clearer.Good: Full-text search
Good: Full-text search
Good: Trigram index for substring
Good: Trigram index for substring
offset-without-limit
When This Rule Triggers
This rule triggers when:OFFSETis used withoutLIMIT
OFFSETis used withLIMITLIMITis used withoutOFFSET- Neither
OFFSETnorLIMITis present
Why It Matters
Returns All Remaining Rows
OFFSET n skips the first n rows, then returns all remaining rows:
- Massive result sets: Transferring millions of rows over the network
- Memory exhaustion: Application runs out of memory
- Slow queries: Processing huge result sets
- Wasted resources: Database and network do unnecessary work
Likely a Mistake
UsingOFFSET without LIMIT is usually a copy-paste error or misunderstanding:
Better Alternatives
Always Use LIMIT with OFFSET
For pagination, always specify both:Consider Keyset Pagination for Large Offsets
For large offsets,OFFSET is inefficient because PostgreSQL must still scan all skipped rows. Use keyset pagination instead:
Examples
Problematic: OFFSET without LIMIT
Problematic: OFFSET without LIMIT
Good: OFFSET with LIMIT
Good: OFFSET with LIMIT
Good: LIMIT without OFFSET
Good: LIMIT without OFFSET
Good: Keyset pagination (no OFFSET)
Good: Keyset pagination (no OFFSET)
Implementation Details
for-update-no-skip
The rule works by:- Walking the SQL AST to find
SelectStmtnodes - Iterating through
LockingClauseitems - Checking if the lock strength is
FOR UPDATEorFOR NO KEY UPDATE - Checking if
WaitPolicyis neitherSKIP LOCKEDnorNOWAIT - Reporting a warning at the estimated location of
FOR UPDATE
for_update_no_skip.go
distinct-on-order
The rule works by:- Walking the SQL AST to find
SelectStmtnodes - Checking if
DistinctClausecontains actual expressions (not just plainDISTINCT) - Verifying that
SortClause(ORDER BY) exists - Comparing
DistinctClausecolumns with leadingSortClausecolumns - Reporting a warning if they don’t match
distinct_on_order.go
like-starts-with-wildcard
The rule works by:- Walking the SQL AST to find
A_Exprnodes withLIKEorILIKEkind - Extracting the pattern string from the right operand
- Checking if the pattern starts with
% - Reporting a warning at the location of the
LIKEexpression
like_starts_with_wildcard.go
offset-without-limit
The rule works by:- Walking the SQL AST to find
SelectStmtnodes - Checking if
LimitOffsetis present - Checking if
LimitCountis absent - Reporting a warning at the location of the
OFFSETclause
offset_without_limit.go