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

推荐订阅源

P
Proofpoint News Feed
WordPress大学
WordPress大学
酷 壳 – CoolShell
酷 壳 – CoolShell
T
Tailwind CSS Blog
J
Java Code Geeks
Engineering at Meta
Engineering at Meta
博客园_首页
G
Google Developers Blog
有赞技术团队
有赞技术团队
Vercel News
Vercel News
Last Week in AI
Last Week in AI
博客园 - 三生石上(FineUI控件)
Jina AI
Jina AI
IT之家
IT之家
The GitHub Blog
The GitHub Blog
Google DeepMind News
Google DeepMind News
Microsoft Security Blog
Microsoft Security Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
博客园 - Franky
A
About on SuperTechFans
The Register - Security
The Register - Security
腾讯CDC
月光博客
月光博客
GbyAI
GbyAI
博客园 - 叶小钗
Blog — PlanetScale
Blog — PlanetScale
D
Docker
D
DataBreaches.Net
博客园 - 聂微东
C
Check Point Blog
T
The Blog of Author Tim Ferriss
人人都是产品经理
人人都是产品经理
F
Full Disclosure
Martin Fowler
Martin Fowler
Apple Machine Learning Research
Apple Machine Learning Research
雷峰网
雷峰网
美团技术团队
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
博客园 - 司徒正美
小众软件
小众软件
量子位
L
LangChain Blog
S
SegmentFault 最新的问题
Hugging Face - Blog
Hugging Face - Blog
大猫的无限游戏
大猫的无限游戏
MongoDB | Blog
MongoDB | Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
I
InfoQ
F
Fortinet All Blogs

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 Aggregate and Window Function Tuning
Philip McCla · 2026-04-30 · via DEV Community
<p><code>GROUP BY</code> and window functions look declarative — the query says what it wants, and PostgreSQL figures out how to compute it. In practice the planner has strong opinions about <em>how</em>: whether to hash or sort, whether to parallelise, whether to spill memory to disk, whether a matching index changes the plan entirely. Learn to read what the planner picked and why, and aggregate-heavy queries become one of the easiest categories to tune.</p> <p>This article is the fifth in the <a href="https://mydba.dev/blog/postgres-query-analysis-complete-guide" rel="noopener noreferrer">Complete Guide to PostgreSQL SQL Query Analysis &amp; Optimization</a> series. Every EXPLAIN block below is captured from a real run on the series' Neon Postgres 17.8 database (500,000-row <code>sim_bp_orders</code> and friends).</p> <h2> The two aggregate strategies </h2> <p>For <code>GROUP BY</code>, the planner chooses primarily between two implementations — plus some parallel and distinct variants layered on top.</p> <p><strong><code>HashAggregate</code></strong> builds a hash table keyed by the group-by columns; each incoming row probes the hash and either creates a new entry or updates an existing one's running aggregate state. Fast when the hash table fits in <code>work_mem</code>. Doesn't care about input order.</p> <p><strong><code>GroupAggregate</code></strong> requires input already sorted on the group-by columns. Each group's rows arrive contiguously, so the aggregate can emit a result row and clear its state between groups — constant memory regardless of group count. Picked when the input is already sorted (typically because the group-by matches an index order) or when the planner thinks the hash table won't fit.</p> <p>The distinguishing signal in EXPLAIN is the node type itself: <code>HashAggregate</code> vs <code>GroupAggregate</code>. When you see <code>Sort → GroupAggregate</code> and no matching index, the planner has decided a sort + streaming aggregate is cheaper than trying to hash. In parallel plans you'll often see a <em>composite</em> shape — <code>Partial HashAggregate</code> inside each worker, topped by <code>Finalize GroupAggregate</code> on the leader — which is a parallel partial-aggregation pattern rather than "just a HashAggregate."</p> <p>Here's that exact shape, from the classic dashboard query "how many orders in each status?":<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight sql"><code><span class="k">SELECT</span> <span class="n">status</span><span class="p">,</span> <span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">),</span> <span class="k">avg</span><span class="p">(</span><span class="n">total_amount_cents</span><span class="p">)</span> <span class="k">FROM</span> <span class="n">sim_bp_orders</span> <span class="k">GROUP</span> <span class="k">BY</span> <span class="n">status</span><span class="p">;</span> </code></pre> </div> <div class="highlight js-code-highlight"> <pre class="highlight plaintext"><code>Finalize GroupAggregate (cost=8334.96..8336.27 rows=5 width=49) (actual time=148.938..151.912 rows=5 loops=1) Group Key: sim_bp_orders.status Buffers: shared hit=3705 -&gt; Gather Merge (actual time=148.924..151.895 rows=15 loops=1) Workers Planned: 2 Workers Launched: 2 -&gt; Sort (actual time=140.390..140.391 rows=5 loops=3) Sort Key: sim_bp_orders.status Sort Method: quicksort Memory: 25kB -&gt; Partial HashAggregate (actual time=140.366..140.367 rows=5 loops=3) Group Key: sim_bp_orders.status Batches: 1 Memory Usage: 24kB -&gt; Parallel Seq Scan on sim_bp_orders (actual time=0.006..32.097 rows=166667 loops=3) Execution Time: 151.973 ms </code></pre> </div> <p>152 ms. This is <strong>parallel partial aggregation</strong>: each parallel worker (plus the leader, making three process loops) computes a partial HashAggregate over its slice of the table (<code>rows=166667 loops=3</code> ≈ 500k total), produces its five-row partial result, sorts those by <code>status</code>, and feeds them up to <code>Gather Merge</code>. The leader then finalises with <code>Finalize GroupAggregate</code> — combining the three sets of partial states into five final rows. Partial aggregation is the reason aggregate queries scale so well with parallel workers: only the partial group states (5 rows per worker here, 15 rows total) cross the worker-to-leader boundary, no matter how big the input was.</p> <p>The <code>Batches: 1 Memory Usage: 24kB</code> on the Partial HashAggregate means the hash table fit in <code>work_mem</code> and didn't spill. Five groups with running sum/count fits easily in 24 kB.</p> <h2> The aggregate spill — and how to diagnose it </h2> <p>Things get interesting when the number of groups grows. A HashAggregate spill on a 117k-group count looks like:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight plaintext"><code>HashAggregate (actual time=347.354..392.138 rows=117060 loops=1) Group Key: u.email Planned Partitions: 4 Batches: 5 Memory Usage: 8241kB Disk Usage: 6920kB </code></pre> </div> <p>The <code>Disk Usage: 6920kB</code> and <code>Batches &gt; 1</code> are the spill signals. PostgreSQL 13+ handles this gracefully — the executor detects that not all groups fit in memory, writes partial state to per-partition spill files, and processes them in additional passes — but the extra I/O is not free. On our database it cost roughly 40% of the query's total time.</p> <p>Two fixes for HashAggregate spills:</p> <ol> <li> <strong>Raise <code>work_mem</code> per-session</strong> so the hash fits in memory. Set per-role (<code>ALTER ROLE analytics SET work_mem = '64MB'</code>) rather than cluster-wide, because <code>work_mem</code> is allocated per sort/hash node <em>per connection</em> and a cluster-wide raise multiplies by concurrency.</li> <li> <strong>Sort + GroupAggregate</strong> is cheaper than a spilling HashAggregate when the group-by column is indexed. Force it with <code>SET enable_hashagg = off;</code> as a diagnostic, and if the Sort + GroupAggregate plan is faster, the underlying issue is "too many groups for current work_mem." Usually the right answer is to raise <code>work_mem</code> for the session anyway, since Sort also uses <code>work_mem</code>.</li> </ol> <p>The MyDBA analyzer rule <code>temp_blocks_written</code> fires when a node's <code>Temp Written Blocks</code> exceeds 100. That field is populated from JSON-format EXPLAIN output — MyDBA's visualiser runs the rules over JSON plans, not the text format pasted here — so the rule fires automatically on both HashAggregate spills and Sort spills when captured through the native integration.</p> <h2> Sort spills — the external merge </h2> <p>When a sort doesn't fit in <code>work_mem</code>, PostgreSQL falls back to an external merge sort: write sorted runs to disk, then merge them. You see this as <code>Sort Method: external merge</code> with a <code>Disk:</code> size in the sort node:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight sql"><code><span class="k">SELECT</span> <span class="n">status</span><span class="p">,</span> <span class="n">percentile_cont</span><span class="p">(</span><span class="mi">0</span><span class="p">.</span><span class="mi">5</span><span class="p">)</span> <span class="n">WITHIN</span> <span class="k">GROUP</span> <span class="p">(</span><span class="k">ORDER</span> <span class="k">BY</span> <span class="n">total_amount_cents</span><span class="p">)</span> <span class="k">AS</span> <span class="n">median</span><span class="p">,</span> <span class="n">percentile_cont</span><span class="p">(</span><span class="mi">0</span><span class="p">.</span><span class="mi">95</span><span class="p">)</span> <span class="n">WITHIN</span> <span class="k">GROUP</span> <span class="p">(</span><span class="k">ORDER</span> <span class="k">BY</span> <span class="n">total_amount_cents</span><span class="p">)</span> <span class="k">AS</span> <span class="n">p95</span> <span class="k">FROM</span> <span class="n">sim_bp_orders</span> <span class="k">GROUP</span> <span class="k">BY</span> <span class="n">status</span><span class="p">;</span> </code></pre> </div> <p>Percentiles are expensive because the implementation needs an ordered sample per group. PostgreSQL's <code>percentile_cont</code> evaluates as an ordered-set aggregate, which requires sorting the input per group:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight plaintext"><code>GroupAggregate (actual time=202.589..358.067 rows=5 loops=1) Group Key: status Buffers: shared hit=3697, temp read=2707 written=2494 -&gt; Sort (actual time=146.514..202.485 rows=500000 loops=1) Sort Key: status Sort Method: external merge Disk: 12048kB Buffers: shared hit=3689, temp read=1506 written=1512 -&gt; Seq Scan on sim_bp_orders (actual time=0.007..49.886 rows=500000 loops=1) Execution Time: 358.230 ms </code></pre> </div> <p>358 ms. The Sort spilled 12 MB of temp files. The <code>GroupAggregate</code> node above it shows its own <code>temp read=2707 written=2494</code> — that's the ordered-set aggregate's internal tuplestore materialising per-group sorted input for the percentile computation, not a generic "every aggregate spills" phenomenon. Ordered-set aggregates like <code>percentile_cont</code>, <code>percentile_disc</code>, and <code>mode()</code> all force per-group materialisation; a simple <code>count()</code> or <code>avg()</code> on the same plan wouldn't produce that second temp-I/O figure. The MyDBA rule <code>sort_on_disk</code> fires on any Sort with <code>Sort Space Type = Disk</code>, which this plan has.</p> <p>The right fix depends on the workload. For a one-off analytical report, raising <code>work_mem</code> to ~40 MB for that session turns the external merge into an in-memory quicksort. For a dashboard that runs this every minute, you want a materialised view:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight sql"><code><span class="k">CREATE</span> <span class="n">MATERIALIZED</span> <span class="k">VIEW</span> <span class="n">order_amount_percentiles</span> <span class="k">AS</span> <span class="k">SELECT</span> <span class="n">status</span><span class="p">,</span> <span class="n">percentile_cont</span><span class="p">(</span><span class="mi">0</span><span class="p">.</span><span class="mi">5</span><span class="p">)</span> <span class="n">WITHIN</span> <span class="k">GROUP</span> <span class="p">(</span><span class="k">ORDER</span> <span class="k">BY</span> <span class="n">total_amount_cents</span><span class="p">)</span> <span class="k">AS</span> <span class="n">median</span><span class="p">,</span> <span class="n">percentile_cont</span><span class="p">(</span><span class="mi">0</span><span class="p">.</span><span class="mi">95</span><span class="p">)</span> <span class="n">WITHIN</span> <span class="k">GROUP</span> <span class="p">(</span><span class="k">ORDER</span> <span class="k">BY</span> <span class="n">total_amount_cents</span><span class="p">)</span> <span class="k">AS</span> <span class="n">p95</span> <span class="k">FROM</span> <span class="n">sim_bp_orders</span> <span class="k">GROUP</span> <span class="k">BY</span> <span class="n">status</span><span class="p">;</span> <span class="c1">-- Refresh on whatever schedule fits your freshness requirement:</span> <span class="n">REFRESH</span> <span class="n">MATERIALIZED</span> <span class="k">VIEW</span> <span class="n">CONCURRENTLY</span> <span class="n">order_amount_percentiles</span><span class="p">;</span> </code></pre> </div> <p><code>REFRESH MATERIALIZED VIEW CONCURRENTLY</code> requires a unique index on the view, reads the source tables outside the refresh window, and replaces the view atomically. The dashboard then queries the view instead of re-running the percentile calculation, and the 358 ms query becomes a 0.5 ms single-row scan.</p> <h2> Window functions </h2> <p>A window function produces an output row for every input row, but with access to a <em>frame</em> of related rows. The syntax:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight sql"><code><span class="n">agg_func</span><span class="p">(...)</span> <span class="n">OVER</span> <span class="p">(</span> <span class="k">PARTITION</span> <span class="k">BY</span> <span class="n">col1</span><span class="p">,</span> <span class="n">col2</span> <span class="c1">-- split input into independent groups</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">col3</span><span class="p">,</span> <span class="n">col4</span> <span class="c1">-- order within each partition</span> <span class="k">ROWS</span> <span class="k">BETWEEN</span> <span class="p">...</span> <span class="k">AND</span> <span class="p">...</span> <span class="c1">-- or RANGE BETWEEN, or GROUPS BETWEEN</span> <span class="p">)</span> </code></pre> </div> <p>The planner implements window functions via a <code>WindowAgg</code> node that consumes an input ordered appropriately and emits one output row per input. If the input isn't already ordered, the planner inserts a <code>Sort</code> before the WindowAgg — which is often where the cost lives.</p> <p>Consider a common pattern: "the most recent order per user." Pre-PostgreSQL 15 the usual rewrite was:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight sql"><code><span class="k">SELECT</span> <span class="n">user_id</span><span class="p">,</span> <span class="n">order_id</span><span class="p">,</span> <span class="n">created_at</span> <span class="k">FROM</span> <span class="p">(</span> <span class="k">SELECT</span> <span class="n">user_id</span><span class="p">,</span> <span class="n">order_id</span><span class="p">,</span> <span class="n">created_at</span><span class="p">,</span> <span class="n">row_number</span><span class="p">()</span> <span class="n">OVER</span> <span class="p">(</span><span class="k">PARTITION</span> <span class="k">BY</span> <span class="n">user_id</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">created_at</span> <span class="k">DESC</span><span class="p">)</span> <span class="k">AS</span> <span class="n">rn</span> <span class="k">FROM</span> <span class="n">sim_bp_orders</span> <span class="p">)</span> <span class="n">t</span> <span class="k">WHERE</span> <span class="n">rn</span> <span class="o">=</span> <span class="mi">1</span> <span class="k">LIMIT</span> <span class="mi">100</span><span class="p">;</span> </code></pre> </div> <p>The PostgreSQL 15+ optimisation for this is the <strong>WindowAgg Run Condition</strong> — the planner notices that <code>WHERE rn = 1</code> can be pushed into the WindowAgg, so it can stop computing row numbers for each partition as soon as <code>rn &gt; 1</code>:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight plaintext"><code>Limit (actual time=0.093..0.525 rows=100 loops=1) Buffers: shared hit=305 -&gt; WindowAgg (actual time=0.092..0.518 rows=100 loops=1) Run Condition: (row_number() OVER (?) &lt;= 1) -&gt; Incremental Sort Sort Key: user_id, created_at DESC Presorted Key: user_id Full-sort Groups: 9 Sort Method: quicksort -&gt; Index Scan using idx_sim_bp_orders_user_id on sim_bp_orders (actual time=0.014..0.339 rows=302 loops=1) Execution Time: 0.543 ms </code></pre> </div> <p>0.54 ms. Two optimisations are visible:</p> <ol> <li> <strong><code>Run Condition: (row_number() OVER (?) &lt;= 1)</code></strong> — the WindowAgg stops producing rows for a partition once <code>rn</code> exceeds 1, so only the first row per user is computed. This lets the plan short-circuit once <code>LIMIT 100</code> is satisfied after only 302 input rows (not the full 500k).</li> <li> <strong><code>Incremental Sort</code> with <code>Presorted Key: user_id</code></strong> — the input arrives already sorted by <code>user_id</code> (from <code>idx_sim_bp_orders_user_id</code>), and the WindowAgg needs it sorted by <code>(user_id, created_at DESC)</code>. An Incremental Sort only sorts <em>within each <code>user_id</code> group</em> rather than globally, which costs drastically less memory and allows pipelined execution.</li> </ol> <p>Even so, a <code>LATERAL</code> join with <code>LIMIT 1</code> inside is often simpler and at least as fast for "top-N per group" with small N.</p> <h2> Frame specifications </h2> <p>Most window function work defaults to an implicit frame clause that trips people up. The rules:</p> <ul> <li> <strong>No <code>ORDER BY</code> clause</strong> → the frame defaults to <code>RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING</code> — the whole partition. This is what you want for <code>sum()</code> or <code>avg()</code> over an entire partition.</li> <li> <strong><code>ORDER BY</code> clause present</strong> → the frame defaults to <code>RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW</code> — the running total up to this row. This is what you want for running sums, but easy to get wrong.</li> <li> <strong>Ranking functions</strong> (<code>row_number()</code>, <code>rank()</code>, <code>dense_rank()</code>) — the frame is irrelevant because the function's result only depends on the ordering.</li> </ul> <p>A common mistake: computing a "running average over the last 7 rows" and getting a running average over all preceding rows because the frame clause was omitted. The fix is explicit:<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight sql"><code><span class="k">avg</span><span class="p">(</span><span class="n">value</span><span class="p">)</span> <span class="n">OVER</span> <span class="p">(</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="nb">timestamp</span> <span class="k">ROWS</span> <span class="k">BETWEEN</span> <span class="mi">6</span> <span class="k">PRECEDING</span> <span class="k">AND</span> <span class="k">CURRENT</span> <span class="k">ROW</span> <span class="p">)</span> </code></pre> </div> <p><code>ROWS BETWEEN N PRECEDING AND CURRENT ROW</code> is a physical window of N+1 rows. <code>RANGE BETWEEN '7 days' PRECEDING AND CURRENT ROW</code> is a logical window based on the ORDER BY value — useful when timestamps aren't evenly spaced. <code>GROUPS BETWEEN N PRECEDING AND CURRENT ROW</code> (PostgreSQL 11+) treats ties as a single "group" and counts those.</p> <h2> LAG, LEAD, and first/last value </h2> <p>The navigation functions — <code>lag(x, n)</code>, <code>lead(x, n)</code>, <code>first_value(x)</code>, <code>last_value(x)</code> — let you reference rows offset from the current one. Classic use: detect state transitions.<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight sql"><code><span class="k">SELECT</span> <span class="n">order_id</span><span class="p">,</span> <span class="n">status</span><span class="p">,</span> <span class="n">lag</span><span class="p">(</span><span class="n">status</span><span class="p">)</span> <span class="n">OVER</span> <span class="p">(</span><span class="k">PARTITION</span> <span class="k">BY</span> <span class="n">user_id</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">created_at</span><span class="p">)</span> <span class="k">AS</span> <span class="n">prev_status</span><span class="p">,</span> <span class="n">created_at</span> <span class="k">FROM</span> <span class="n">sim_bp_orders</span> <span class="k">WHERE</span> <span class="n">user_id</span> <span class="o">=</span> <span class="mi">42</span><span class="p">;</span> </code></pre> </div> <p>Each row gets the status of the user's <em>previous</em> order. The window can then be wrapped in a subquery or CTE to find "orders where the status changed":<br> </p> <div class="highlight js-code-highlight"> <pre class="highlight sql"><code><span class="k">WITH</span> <span class="n">ordered</span> <span class="k">AS</span> <span class="p">(</span> <span class="k">SELECT</span> <span class="n">order_id</span><span class="p">,</span> <span class="n">status</span><span class="p">,</span> <span class="n">created_at</span><span class="p">,</span> <span class="n">lag</span><span class="p">(</span><span class="n">status</span><span class="p">)</span> <span class="n">OVER</span> <span class="p">(</span><span class="k">PARTITION</span> <span class="k">BY</span> <span class="n">user_id</span> <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">created_at</span><span class="p">)</span> <span class="k">AS</span> <span class="n">prev_status</span> <span class="k">FROM</span> <span class="n">sim_bp_orders</span> <span class="p">)</span> <span class="k">SELECT</span> <span class="o">*</span> <span class="k">FROM</span> <span class="n">ordered</span> <span class="k">WHERE</span> <span class="n">prev_status</span> <span class="k">IS</span> <span class="k">DISTINCT</span> <span class="k">FROM</span> <span class="n">status</span><span class="p">;</span> </code></pre> </div> <p>Two performance notes. First, <code>last_value()</code> with a default frame is surprising — because the default frame ends at the current row, <code>last_value()</code> returns the current row's value, not the partition's last. To actually get the partition's last value, specify <code>ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING</code>. Second, LAG and LEAD compile to very cheap operations (just a pointer to the previous/next row in the window), while first_value/last_value with an explicit full-partition frame can force materialisation.</p> <h2> Aggregate-related indexes </h2> <p>An index on the GROUP BY columns is a straightforward win when it exists: the planner can use <code>GroupAggregate</code> over an index scan and skip the hash build entirely. The index has to cover the group key in <em>exactly</em> the right order — a composite index on <code>(status, created_at)</code> serves <code>GROUP BY status</code>, but a <code>(created_at, status)</code> doesn't.</p> <p>For queries that frequently aggregate a narrow window of a big table (<code>WHERE created_at &gt; ... GROUP BY user_id</code>), a partial index or materialised view of the aggregate result is usually the right answer, because re-aggregating millions of rows every time beats out any planner optimisation. Precomputation is the most robust performance tactic for aggregates.</p> <h2> Quick diagnostic checklist </h2> <p>When an aggregate query is slow:</p> <ol> <li> <strong>Is the aggregate node a <code>HashAggregate</code> with <code>Batches &gt; 1</code> or <code>Disk Usage &gt; 0</code>?</strong> The hash table spilled. Raise <code>work_mem</code> for the session, or create a supporting index to enable GroupAggregate instead.</li> <li> <strong>Is there a <code>Sort</code> above a <code>GroupAggregate</code> with <code>Sort Method: external merge</code>?</strong> The sort spilled. Same fix: more <code>work_mem</code>, or an index that provides pre-sorted input.</li> <li> <strong>Is there a <code>WindowAgg</code> over a <code>Sort</code> that processes all input before the <code>LIMIT</code>?</strong> Check if a <code>Run Condition</code> is possible (PG 15+) or if the problem can be rewritten as <code>LATERAL + LIMIT N</code>.</li> <li> <strong>Is the aggregate running every time the dashboard loads?</strong> Move it behind a materialised view refreshed on schedule. This is usually the biggest win of all.</li> <li> <strong>Does the MyDBA analyzer flag <code>sort_on_disk</code>, <code>hash_batches_spill</code>, or <code>temp_blocks_written</code>?</strong> These are the three rules that specifically target aggregate-related spills; if any fire, follow the suggestion inline.</li> </ol> <h2> Next steps </h2> <p>Aggregates interact closely with the shape of your WHERE clauses — a filter that narrows the input set before aggregation is almost always cheaper than aggregating and then filtering. The next article, <a href="https://mydba.dev/blog/postgres-where-clause-optimization" rel="noopener noreferrer">WHERE Clause Optimisation</a>, covers sargability and composite-index ordering in detail, with an eye toward getting predicates to apply as early in the plan as possible.</p> <h1> postgres #performance #database #sql </h1> <p>Full article with the complete series: <a href="https://mydba.dev/blog/postgres-aggregate-window-tuning" rel="noopener noreferrer">https://mydba.dev/blog/postgres-aggregate-window-tuning</a></p>