惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

T
Tailwind CSS Blog
大猫的无限游戏
大猫的无限游戏
L
LINUX DO - 热门话题
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
雷峰网
雷峰网
aimingoo的专栏
aimingoo的专栏
博客园_首页
MongoDB | Blog
MongoDB | Blog
V
V2EX
GbyAI
GbyAI
量子位
Microsoft Azure Blog
Microsoft Azure Blog
有赞技术团队
有赞技术团队
G
Google Developers Blog
云风的 BLOG
云风的 BLOG
B
Blog
Microsoft Security Blog
Microsoft Security Blog
S
SegmentFault 最新的问题
O
OpenAI News
N
News and Events Feed by Topic
博客园 - Franky
爱范儿
爱范儿
Forbes - Security
Forbes - Security
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
V2EX - 技术
V2EX - 技术
Application and Cybersecurity Blog
Application and Cybersecurity Blog
N
News and Events Feed by Topic
N
News | PayPal Newsroom
Schneier on Security
Schneier on Security
Cloudbric
Cloudbric
Security Archives - TechRepublic
Security Archives - TechRepublic
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Recent Commits to openclaw:main
Recent Commits to openclaw:main
人人都是产品经理
人人都是产品经理
P
Privacy International News Feed
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
B
Blog RSS Feed
阮一峰的网络日志
阮一峰的网络日志
D
DataBreaches.Net
Last Week in AI
Last Week in AI
罗磊的独立博客
Spread Privacy
Spread Privacy
Recent Announcements
Recent Announcements
The Cloudflare Blog
Google DeepMind News
Google DeepMind News
AWS News Blog
AWS News Blog
The Register - Security
The Register - Security
Y
Y Combinator Blog
J
Java Code Geeks
I
Intezer

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
PostgreSQL Query Rewriting Techniques
Philip McCla · 2026-05-04 · via DEV Community

PostgreSQL Query Rewriting Techniques

The previous articles in this series covered performance problems you fix by adding indexes, restructuring joins, or tuning memory. This one is about the queries where the plan is "fine" — every node is doing something reasonable — but the query itself is asking the wrong question, producing unnecessarily large intermediate results or forcing the planner down a path that a different SQL shape would avoid.

These rewrites don't change what the query returns. They change how PostgreSQL goes about computing it. Learn to recognise the patterns and most of them are mechanical — if the original form matches X, rewrite to Y — and the performance improvement is often an order of magnitude or more with no downside.

This article is the seventh in the Complete Guide to PostgreSQL SQL Query Analysis & Optimization series. Every EXPLAIN block below is captured from the same Neon Postgres 17.8 database used throughout.

Offset pagination → keyset pagination

The single highest-impact rewrite in this article. OFFSET N LIMIT M is the default pagination shape in most ORMs and REST API frameworks. It's also a performance landmine as soon as users deep-paginate. To return page 1000 of 500,000 rows (20 per page), PostgreSQL must read and discard 19,980 rows before returning the 20 you want. Page 1 is fast; page 1000 is slow; page 10000 is a disaster.

Captured against our 500,000-row sim_bp_orders table — "page 24000 of 25000, 20 orders per page":

SELECT order_id, user_id, created_at
FROM sim_bp_orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 480000;

Enter fullscreen mode Exit fullscreen mode

Limit  (cost=28511.34..28512.53 rows=20 width=16) (actual time=1900.713..1900.731 rows=20 loops=1)
  Buffers: shared hit=481693 read=1775
  ->  Index Scan Backward using idx_sim_bp_orders_created_at
        (actual time=0.018..1878.609 rows=480020 loops=1)
 Execution Time: 1900.750 ms

Enter fullscreen mode Exit fullscreen mode

1.9 seconds for 20 rows. The Index Scan Backward returns rows=480020 before the Limit takes 20 — PostgreSQL walked the created_at index backwards, visited every heap tuple for visibility checks, and discarded 99.996% of them. Buffers: shared hit=481693 read=1775 is 3.8 GB of page traffic for a result the size of a tweet.

The fix is keyset pagination — instead of OFFSET 480000, remember the cursor value of the last row you returned and ask for rows less than that:

-- Pass the (created_at, order_id) from the last row of the previous page.
SELECT order_id, user_id, created_at
FROM sim_bp_orders
WHERE created_at < '2024-03-01'
ORDER BY created_at DESC
LIMIT 20;

Enter fullscreen mode Exit fullscreen mode

Limit  (actual time=0.979..1.014 rows=20 loops=1)
  Buffers: shared hit=22 read=1
  ->  Index Scan Backward using idx_sim_bp_orders_created_at
        Index Cond: (created_at < '2024-03-01'::timestamptz)
        (actual time=0.978..1.010 rows=20 loops=1)
 Execution Time: 1.032 ms

Enter fullscreen mode Exit fullscreen mode

1 ms, 23 buffers hit. The Index Cond means the planner could start the index scan from the cursor position rather than the beginning — no discarded rows, no wasted buffer reads. Page 1 and page 10,000 have identical cost.

Three things to know about keyset pagination:

  1. Use a composite cursor for uniqueness. ORDER BY created_at DESC isn't a deterministic total order unless created_at is unique. For production systems, use (created_at, id) or similar: WHERE (created_at, order_id) < ('2024-03-01 14:22:00+00', 984523) ORDER BY created_at DESC, order_id DESC LIMIT 20. This ensures no rows are skipped or duplicated at page boundaries when multiple rows share the same timestamp.
  2. The index has to match the sort. ORDER BY created_at DESC, order_id DESC works against (created_at DESC, order_id DESC) directly or (created_at, order_id) read backwards. Mismatches force an in-memory sort that undoes the keyset win.
  3. You give up random-access "jump to page N" semantics. Keyset pagination is forward/backward through an ordered stream. Most APIs and infinite-scroll UIs don't actually need random access; if yours does, you're stuck with OFFSET (or need a completely different data model).

Correlated scalar subquery → aggregating JOIN

A scalar subquery in the SELECT list runs once per outer row (SubPlan N in the plan). When the outer set is large, this is O(n²). The rewrite is a LEFT JOIN to a pre-aggregated table or CTE:

-- Before: SubPlan runs once per user.
SELECT u.user_id,
       u.email,
       (SELECT count(*) FROM sim_bp_orders o
        WHERE o.user_id = u.user_id AND o.status = 'pending') AS pending_count
FROM sim_bp_users u
WHERE u.status = 'active';

-- After: single aggregation, left-joined.
SELECT u.user_id, u.email, COALESCE(p.pending_count, 0) AS pending_count
FROM sim_bp_users u
LEFT JOIN (
    SELECT user_id, count(*) AS pending_count
    FROM sim_bp_orders
    WHERE status = 'pending'
    GROUP BY user_id
) p ON p.user_id = u.user_id
WHERE u.status = 'active';

Enter fullscreen mode Exit fullscreen mode

The rewrite computes all per-user counts in a single aggregating scan over sim_bp_orders, then joins them against users. On large outer sets (say, all 200k active users instead of LIMIT 100), the rewrite is usually 20-100× faster because the aggregation happens once rather than 200,000 times.

For "top-N related rows per outer" (not just count), use LATERAL JOIN with LIMIT N.

NOT INNOT EXISTS

The most insidious bug in SQL, bar none. NOT IN returns no rows whenever the inner set contains a single NULL, because x NOT IN (a, b, NULL) evaluates to x <> a AND x <> b AND x <> NULL, and x <> NULL is unknown, making the whole AND evaluate to unknown (not-true, hence excluded).

-- If any user in the inner query has a NULL email, this returns empty.
SELECT * FROM customers
WHERE email NOT IN (SELECT email FROM unsubscribed_users);

-- Correct, NULL-safe equivalent:
SELECT * FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM unsubscribed_users u WHERE u.email = c.email);

Enter fullscreen mode Exit fullscreen mode

NOT EXISTS uses existence semantics, not three-valued logic, so NULLs don't poison the result. The two forms also often produce different plans — NOT EXISTS usually becomes an Anti Semi Join, which PostgreSQL executes as cheaply as a regular join. NOT IN with a nullable inner column can force a hash anti-join that's aware of NULL semantics, and that's slower.

Rule: never write NOT IN against a subquery unless you've confirmed the compared column is NOT NULL at the schema level. In production code, just default to NOT EXISTS.

DISTINCTGROUP BY

SELECT DISTINCT tells PostgreSQL to deduplicate the output; GROUP BY on the same columns does the same thing. When the only goal is deduplication (no aggregate functions), the two are equivalent, and the planner usually produces the same plan for each. But GROUP BY is strictly more flexible — it composes with HAVING, plays nicely with window functions, and handles expressions more cleanly.

The rewrite that actually matters is when DISTINCT is used in a query shape that's really asking for something else. "The first order per user" is often written as:

-- Wrong: this gets any order, not the first.
SELECT DISTINCT ON (user_id) user_id, order_id, created_at
FROM sim_bp_orders;

Enter fullscreen mode Exit fullscreen mode

DISTINCT ON (user_id) returns one row per user_id, but which row is unspecified without an ORDER BY. Usually you want:

SELECT DISTINCT ON (user_id) user_id, order_id, created_at
FROM sim_bp_orders
ORDER BY user_id, created_at DESC;

Enter fullscreen mode Exit fullscreen mode

This returns the latest order per user, provided the ORDER BY starts with the DISTINCT ON column. An index on (user_id, created_at DESC) lets this run as an index scan that emits one row per user without a separate sort.

DISTINCT ON is a PostgreSQL extension (not standard SQL) but it's the cleanest expression of "top-1 per group" when the pattern fits. For top-N with N > 1, use LATERAL (below) or a window function with a Run Condition.

Chunked deletes and updates

Large DELETE or UPDATE statements take locks on every row they touch, generate WAL proportional to the row count, and can trigger autovacuum storms. A 10-million-row delete often locks out writers for minutes. The rewrite is to do it in chunks:

-- Problematic: single massive delete.
DELETE FROM sim_bp_logs WHERE created_at < now() - interval '90 days';

-- Chunked: loop until no more rows to delete.
DO $$
DECLARE
    deleted_count int;
BEGIN
    LOOP
        DELETE FROM sim_bp_logs
        WHERE log_id IN (
            SELECT log_id FROM sim_bp_logs
            WHERE created_at < now() - interval '90 days'
            LIMIT 10000
        );
        GET DIAGNOSTICS deleted_count = ROW_COUNT;
        EXIT WHEN deleted_count = 0;
        COMMIT;  -- Releases locks; next iteration starts fresh txn.
    END LOOP;
END $$;

Enter fullscreen mode Exit fullscreen mode

Each chunk commits separately, releasing locks and letting autovacuum catch up between iterations. Use LIMIT + IN (SELECT ... LIMIT ...) because DELETE ... LIMIT isn't valid PostgreSQL syntax (unlike MySQL).

The same pattern applies to bulk UPDATEs. Batch size depends on row width and lock contention tolerance — 1,000 for wide rows with heavy concurrent load, up to 100,000 for narrow rows on an off-hours maintenance window.

INSERT ... ON CONFLICT

Pre-existing code often uses a read-then-write pattern for upserts:

-- Anti-pattern: race condition between the SELECT and INSERT.
SELECT 1 FROM sim_bp_users WHERE email = $1;
-- (application: if not found) INSERT INTO sim_bp_users ...;

Enter fullscreen mode Exit fullscreen mode

Two round trips, and two sessions can both read "not found" and both try to insert, producing a unique-constraint violation. The PostgreSQL idiom is INSERT ... ON CONFLICT:

INSERT INTO sim_bp_users (email, username, status)
VALUES ($1, $2, 'active')
ON CONFLICT (email) DO UPDATE
    SET username = EXCLUDED.username,
        status   = 'active'
RETURNING user_id;

Enter fullscreen mode Exit fullscreen mode

One round trip, atomic, race-free. EXCLUDED references the row that would have been inserted (before the conflict). For "do nothing on duplicate," use ON CONFLICT (col) DO NOTHING. The conflict target must be a column or constraint that has a unique index — without one, PostgreSQL has no way to detect "a conflicting row already exists."

SELECT * in production queries

Not a rewrite of the query's logic, but a rewrite of its projection. SELECT * from a wide table pulls every column over the wire and through every plan node — Index Only Scans degrade to regular Index Scans (heap fetches required for the extra columns), join memory usage multiplies, sort widths explode.

The specific cost isn't always catastrophic, but the robustness cost is. A column-type change on an upstream table can break downstream consumers that didn't know they depended on the old width. In production code, name every column you actually need.

The exception: dump tools, ad-hoc debugging, and CTEs that genuinely pass all columns through. Context-dependent, but the default should be "name the columns."

HAVING vs WHERE

HAVING filters after aggregation; WHERE filters before. If a predicate could apply before aggregation, it should — the aggregate then operates on fewer rows. A classic misuse:

-- Inefficient: aggregate over all orders, then filter.
SELECT user_id, count(*) AS order_count
FROM sim_bp_orders
GROUP BY user_id
HAVING user_id IN (SELECT user_id FROM active_users);

-- Better: filter before aggregation.
SELECT user_id, count(*) AS order_count
FROM sim_bp_orders
WHERE user_id IN (SELECT user_id FROM active_users)
GROUP BY user_id;

Enter fullscreen mode Exit fullscreen mode

The WHERE clause restricts the set of rows that go into the GROUP BY, so the aggregate runs over a smaller input. Only predicates that depend on the aggregate result (e.g., HAVING count(*) > 5) belong in HAVING; anything else is almost always more efficient in WHERE.

The planner usually pushes predicates from HAVING to WHERE when it's safe, but not always — especially when there are subqueries or complex expressions involved. Writing the filter in WHERE to begin with removes the uncertainty.

Composite rewrites: correlated subquery + LATERAL + keyset pagination

Real-world queries often combine several anti-patterns. The "show me the latest 20 orders for each of the top 100 users by lifetime spend" query is classic:

-- Naive: one subquery for the user list, window function for the per-user top-N.
WITH top_users AS (
    SELECT user_id
    FROM sim_bp_orders
    GROUP BY user_id
    ORDER BY sum(total_amount_cents) DESC
    LIMIT 100
)
SELECT * FROM (
    SELECT o.*,
           row_number() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
    FROM sim_bp_orders o
    WHERE user_id IN (SELECT user_id FROM top_users)
) t WHERE rn <= 20;

Enter fullscreen mode Exit fullscreen mode

The CTE lists 100 top users; the window function computes row numbers for all their orders (potentially thousands each); then the outer WHERE keeps only the top 20 per user. The window function is doing 10-100× the work that's actually needed.

Rewritten with LATERAL + LIMIT:

WITH top_users AS (
    SELECT user_id, sum(total_amount_cents) AS total_spent
    FROM sim_bp_orders
    GROUP BY user_id
    ORDER BY 2 DESC
    LIMIT 100
)
SELECT t.user_id, t.total_spent, recent.*
FROM top_users t,
LATERAL (
    SELECT order_id, total_amount_cents, created_at
    FROM sim_bp_orders
    WHERE user_id = t.user_id
    ORDER BY created_at DESC
    LIMIT 20
) recent;

Enter fullscreen mode Exit fullscreen mode

For each of the 100 top users, a LATERAL subquery returns their 20 most recent orders — at most 2000 rows total, vs potentially hundreds of thousands in the window-function form. PostgreSQL 15+ can sometimes optimise the window-function form via Run Condition, but LATERAL is both clearer and more reliably cheap.

When not to rewrite

Every rewrite has a small risk of changing semantics in an edge case. Before deploying:

  • Diff the results. Run the old and new forms against the same data; check the row counts and a representative sample match exactly.
  • Check the plan with EXPLAIN ANALYZE. The rewrite should show the cost improvement you expect; if it doesn't, there's a case where the planner disagreed.
  • Run both under load. Synthetic benchmarks rarely capture the real cache and concurrency effects. A rewrite that's 10× faster in isolation might be only 2× faster in production — still worth it, but measure.

Rewriting for performance is the right move after indexing, before buying bigger hardware. The patterns in this article cover most of what you'll find in a typical OLTP codebase; for the actually-broken queries — the ones that are wrong by construction — see the companion article on PostgreSQL Query Anti-Patterns and Common Mistakes.


postgres #performance #database #sql

Full series and canonical version: https://mydba.dev/blog/postgres-query-rewriting-techniques