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

推荐订阅源

V
V2EX - 技术
D
DataBreaches.Net
阮一峰的网络日志
阮一峰的网络日志
Recent Announcements
Recent Announcements
V
V2EX
Hugging Face - Blog
Hugging Face - Blog
T
The Exploit Database - CXSecurity.com
Simon Willison's Weblog
Simon Willison's Weblog
Cisco Talos Blog
Cisco Talos Blog
Microsoft Security Blog
Microsoft Security Blog
C
Cyber Attacks, Cyber Crime and Cyber Security
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
K
Kaspersky official blog
F
Fortinet All Blogs
GbyAI
GbyAI
Forbes - Security
Forbes - Security
The Cloudflare Blog
博客园 - 司徒正美
博客园_首页
量子位
Schneier on Security
Schneier on Security
G
GRAHAM CLULEY
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
P
Proofpoint News Feed
N
News | PayPal Newsroom
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
博客园 - 聂微东
T
Tor Project blog
V
Vulnerabilities – Threatpost
Y
Y Combinator Blog
Jina AI
Jina AI
Help Net Security
Help Net Security
T
Threat Research - Cisco Blogs
Recent Commits to openclaw:main
Recent Commits to openclaw:main
C
Cybersecurity and Infrastructure Security Agency CISA
Project Zero
Project Zero
N
News and Events Feed by Topic
I
Intezer
B
Blog
美团技术团队
C
CERT Recently Published Vulnerability Notes
NISL@THU
NISL@THU
L
LINUX DO - 最新话题
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Blog — PlanetScale
Blog — PlanetScale
AWS News Blog
AWS News Blog
T
Tailwind CSS Blog
The Last Watchdog
The Last Watchdog
雷峰网
雷峰网
有赞技术团队
有赞技术团队

The Practical Developer

The Libuv Thread Pool Trap: Why Node.js Async APIs Stall Under Load Postgres Covering Indexes with INCLUDE: Eliminate Heap Fetches on Read-Heavy Workloads Postgres DISTINCT ON: The Fastest Way to Get the Latest Row Per Group Postgres Transaction Isolation: The Anomalies Your App Actually Faces in Production Linux TCP Tuning for Node.js Microservices: The Kernel Settings That Stop Silent Connection Drops Under Load Postgres HOT Updates and Fillfactor: Why Not All Writes Are Created Equal Database Connection Pool Leaks: Finding the Promise That Never Returns Its Seat Linux OOM Killer in Production: Why Your Node.js Containers Die Without a Stack Trace Postgres Materialized Views: Refresh Strategies That Do Not Lock Your Dashboards API Dependency Health Checks: Why /health Is Not Enough Authorization with Zanzibar Tuples: How Google Manages Permissions and How To Build the Same Check in Node.js Postgres Advisory Locks: The 20-Character Primitive That Replaces Redis for Coordination Dead Letter Queues: The Message Queue Pattern That Saves You at 2 a.m. File Descriptor Exhaustion: The Kernel Limit That Silently Drops Node.js Connections Graceful Degradation: The Pattern That Turns Total Outages into Partial Success PostgreSQL Full-Text Search: Dropping Elasticsearch for 90% of Use Cases S3 Presigned Multipart Uploads: Stop Your API Server from Being a File Upload Bottleneck MessagePack vs JSON: The Binary Serialization Switch That Cut Our Internal RPC Overhead by 40% DNS Caching in Node.js: The Silent Cause of Production Latency Spikes Reliable Cron Jobs: The Pattern That Stops Double Runs, Missed Executions, And The 2 AM Page GraphQL Query Complexity: Stop the OOM Query Before It Reaches Your Resolver Node.js Event Loop Lag: The Hidden Metric Behind Random Latency Spikes API Request Validation with Zod: The Schema That Catches Bad Input Before It Corrupts Your Database Load Shedding in Node.js: How to Reject Traffic Before You Drown Request Hedging: Cut Tail Latency In Half Without Overprovisioning Git Bisect: The Automated Binary Search That Finds Breaking Commits in Minutes Node.js Garbage Collection Tuning: Stop Letting V8 Pause Your Event Loop Node.js Server Timeouts: The Settings That Stop Slow Clients from Holding Sockets Hostage Postgres BRIN Indexes: The Time-Series Secret That Shrinks Indexes by 99% Event Sourcing with PostgreSQL: The Pragmatic 80% Solution Node.js Cluster Mode: Scaling the Event Loop Across CPU Cores Request Coalescing with the Singleflight Pattern: Stop Drowning Your Database on Every Cache Miss The Bulkhead Pattern: Why One Slow Endpoint Should Not Drown Your Whole Service Node.js AsyncLocalStorage: End-to-End Request Context Without the Propagation Hell Postgres Deadlocks: Logging the Victim, Reproducing the Race, and Fixing the Lock Order Your Node.js HTTP Client Is the Bottleneck: Connection Pool Tuning That Works Optimistic Locking in Postgres: Stop Losing Data to Race Conditions Postgres Read Replicas: Stop Serving Stale Data to Your Users Cursor Pagination: Why Offset Queries Explode at Scale and How to Fix Them Node.js Worker Threads: 60 Lines That Stop a CSV Upload from Timing Out Every Other Request Reliable Webhook Delivery: Architecture for Outbound HTTP You Can Trust Request Timeouts and Deadline Propagation: Stop the Chain of Slowness Advanced Security Practices in Node.js Graceful Shutdown in Node.js: The 40 Lines That Stop 502s During Deploys Finding Node.js Memory Leaks with Heap Snapshots Idempotency Keys in 30 Lines: Stop Your Webhook From Charging Customers Twice Backpressure In Node.js: The Fix For Slow-Motion Queue Meltdowns Retries Done Right: Jitter, Budgets, and the Stampede You Did Not See Coming The Cache Stampede: Why Your "Just Add Redis" Layer Crashes Postgres at 3 a.m. Postgres SKIP LOCKED: An 80-Line Job Queue You Can Run Without Redis Stop Doing Work Nobody Wants: AbortController in Node.js, Done Right The N+1 Query Problem: We Found 23 In One Codebase And Killed Every One I Tried 5 AI Coding Tools for a Month. Here Is What I Actually Use CI/CD From Zero to Production in 30 Minutes With GitHub Actions Node.js vs Bun vs Deno: Which Runtime Should You Pick in 2025? Kubernetes Resource Requests And Limits: The Numbers That Decide If Your Cluster Is Stable The Three Pillars of Observability Are A Myth: What Actually Matters In Production pnpm Vs npm Vs yarn Vs Bun For Monorepos: Which One Earns The Migration In 2024 JSONB Indexing In Postgres: GIN Vs Expression Indexes, And When Each Is The Right Choice A Code Review Checklist That Ends The Same Three Arguments Every Sprint gRPC Vs REST In 2024: When The Switch Pays For Itself React Suspense For Data Fetching: The Pattern That Replaces Half Your Loading State Code The Five-Stage Rollout: How To Ship A Risky Change Without Holding Your Breath GitHub Actions In A Monorepo: Caching, Path Filters, And Secret Boundaries That Actually Work The Blameless Postmortem That Actually Improves Things: A Template And Six Hard-Won Rules Recursive CTEs In Postgres: How To Query A Tree Without N Round Trips Node.js Streams: When They Actually Help, And When They Just Add Complexity Playwright Vs Cypress In 2024: The Honest Comparison Of Which One Earns The Test Time React Server Components: The Mental Model That Makes The "use client" Boundary Obvious Pod Disruption Budgets: The K8s Object That Keeps Your Service Up During Cluster Maintenance Postgres LISTEN/NOTIFY: The Pub/Sub You Already Have And Are Not Using Chaos Engineering Starter Kit: The Five Drills That Don't Need Netflix-Scale Spec-Driven API Development With OpenAPI: How To Stop Drifting From Your Docs Saga Pattern vs Two-Phase Commit: Distributed Transactions Without The Lies Kubernetes Autoscaling Beyond CPU: The Custom-Metric HPA Pattern That Actually Works Postgres Partitioning For Time-Series: The Boring Setup That Saves Your Database Distributed Locks With Redis: An Honest Look At Redlock And When You Don't Need It HTTP/2 vs HTTP/3: What Actually Changes For Your App, And What Doesn't Image Optimization For The Web In 2023: srcset, AVIF, And The Lighthouse Score You Actually Want Kafka vs RabbitMQ: A Decision Tree That Doesn't Hate You UUID vs Bigint Primary Keys In Postgres: The Index Math That Decides For You Flame Graphs: How To Find The Slow Function In 30 Seconds Without Profiling Theatre Postgres Streaming Vs. Logical Replication: Which One Solves Your Actual Problem ESLint Rules That Earn Their Keep: The Twelve I Enable On Every Project Pre-Commit Hooks That Pay For Themselves: Husky, lint-staged, And The Five Rules That Stick Zero-Downtime Database Migrations: The Six-Step Pattern That Rules Them All Circuit Breakers In Node.js: 50 Lines That Stop A Failing Dependency From Taking Down Your Service Postgres VACUUM Is Not Magic: How Your Hot Table Bloats To 80GB And How To Fix It Kubernetes Liveness And Readiness Probes: The Difference That Causes Half Your Outages Rate Limiting In Production: A Token Bucket In 30 Lines Of Redis The Outbox Pattern: How To Stop Losing Events When Postgres And Kafka Disagree Load Testing With k6: The Three Scenarios That Find Real Bugs (Not Synthetic Numbers) Postgres Row-Level Security For Multi-Tenant Apps: The Pattern That Stops You From Leaking Data Rebase vs. Merge: The Team Policy That Ends The Argument Forever OpenTelemetry in Node.js: Distributed Tracing That Actually Helps During an Incident Feature Flags That Pay Rent: The 4 Flag Types And When To Delete Each ETag, Last-Modified, and the Caching Headers Most APIs Get Wrong Connection Pooling Without the Cargo Cult: pgbouncer in 100 Lines of Config JSONB Is Not a Schema: When To Reach For It in Postgres, And When To Stop Bash Strict Mode: The Three Lines That Stop Your Deploy Script From Lying To You
Postgres Partial Indexes: Stopping Soft Deletes from Ruining Your Query Performance
The Practica · 2026-05-15 · via The Practical Developer

Your users table has 10 million rows. Two hundred thousand of those users are active. The rest were soft-deleted over the years, their deleted_at timestamp quietly marking them as gone.

You look up a user by email: SELECT * FROM users WHERE email = 'alice@example.com' AND deleted_at IS NULL. You have an index on email. The query planner says “Index Scan” and returns in 2 ms. You ship it.

Six months later the table has 50 million rows. The same query takes 45 ms. The index is 2 GB. Your working set no longer fits in RAM. What changed? The index on email now covers 50 million rows, even though your application never queries deleted users. Postgres is crawling through a graveyard to find the living.

This is the soft-delete performance cliff, and it is predictable. The fix is a partial index: an index that only includes the rows your application actually queries. Not a new table, not a partitioning scheme, not a materialized view. Just a WHERE clause in your CREATE INDEX statement.

This post shows the exact syntax, the planner proof that it works, the write-amplification trade-off, and the three ways teams accidentally neutralize them.

The soft-delete trap

Most ORMs default to soft deletes. Rails, Django, Laravel, Prisma, TypeORM (all of them can add a deleted_at column and hide rows from SELECT queries automatically). The generated SQL looks innocent:

SELECT * FROM users
WHERE email = 'alice@example.com'
  AND deleted_at IS NULL
LIMIT 1;

The problem is the index. A standard index on email covers every row in the table, including the 9.8 million deleted ones. When Postgres executes the query, it walks the index to find alice@example.com, then checks deleted_at IS NULL for each match. If Alice was deleted three years ago, Postgres finds her in the index, fetches the heap tuple, sees deleted_at is set, and discards the row. The work was already done.

As the table grows, the index grows linearly with total history, not with active data. The shared_buffers cache that held the entire index in memory six months ago now only holds a fraction. Every lookup starts touching disk.

The fix is to tell Postgres exactly what you already told your ORM: “we only query rows where deleted_at IS NULL.”

Creating a partial index

CREATE INDEX idx_users_email_active
ON users (email)
WHERE deleted_at IS NULL;

That WHERE clause is the entire trick. The index only contains entries for rows where deleted_at IS NULL. If 98% of your table is soft-deleted, the index is roughly 98% smaller than the full index. On disk, it is not 2 GB. It is 40 MB.

More importantly, the index is structurally unable to contain deleted rows. When Postgres plans the query WHERE email = ? AND deleted_at IS NULL, it recognizes that the partial index satisfies both conditions. It never even considers deleted rows.

Here is the before-and-after EXPLAIN (ANALYZE, BUFFERS) on a 50M-row table with 2M active rows:

Full index:

Index Scan using idx_users_email on users
  Index Cond: (email = 'alice@example.com')
  Filter: (deleted_at IS NULL)
  Rows Removed by Filter: 1
  Buffers: shared hit=12 read=3
  Time: 42.315 ms

Partial index:

Index Scan using idx_users_email_active on users
  Index Cond: (email = 'alice@example.com')
  Buffers: shared hit=2
  Time: 0.142 ms

The difference is not just size. The partial index is a covering index for the query condition. There is no Filter line because the index itself enforces the predicate. The planner does not need to check the heap to reject deleted rows. The deleted rows are not in the index at all.

When partial indexes beat partitioning

Some teams solve this by partitioning: one partition for active rows, one for deleted. Partitioning works, but it is schema surgery. Foreign keys become complicated. Some queries need to scan all partitions. Your ORM may not support it cleanly.

Partial indexes are a zero-downtime, zero-migration alternative. They live in the same table. They do not break foreign keys. They do not require application changes. The only requirement is that your query predicate matches the index predicate exactly.

The three ways teams break partial indexes

Partial indexes are not fire-and-forget. Three mistakes make them invisible to the planner.

1. The predicate does not match

Postgres matches partial indexes with literal equality. If your index is WHERE deleted_at IS NULL, but your ORM generates WHERE deleted_at IS NULL AND archived = false, the planner will not use the partial index because the index does not guarantee archived = false.

Fix: make the partial index match the query.

CREATE INDEX idx_users_email_active_unarchived
ON users (email)
WHERE deleted_at IS NULL AND archived = false;

If your ORM always adds both conditions, the index must include both. The cost is a slightly larger index, but it is still far smaller than a full index.

2. Functions and casts in the predicate

If your query uses a function that transforms the column, the planner cannot match the index unless the index uses the exact same expression. This is where partial indexes overlap with expression indexes.

-- Query generated by an ORM using lower() for case-insensitive lookup
SELECT * FROM users
WHERE lower(email) = 'alice@example.com'
  AND deleted_at IS NULL;

-- Wrong: planner cannot use this
CREATE INDEX idx_users_email_active ON users (email)
WHERE deleted_at IS NULL;

-- Right: match the expression exactly
CREATE INDEX idx_users_lower_email_active
ON users (lower(email))
WHERE deleted_at IS NULL;

The partial index predicate and the index expression must both match the query.

3. Parameterized queries with generic plans

Prepared statements and ORM query builders sometimes use generic plans that do not inline the literal NULL check. If the planner sees deleted_at = $1 where $1 might be NULL or a timestamp, it cannot prove the query satisfies deleted_at IS NULL. It falls back to the full index or a seq scan.

Fix: ensure the ORM generates a literal IS NULL in the SQL, or set plan_cache_mode = force_custom_plan if generic plans are ruining partial index usage. Most modern ORMs do the right thing here, but it is worth verifying with EXPLAIN.

Measuring the win

Size comparison is immediate:

SELECT
  pg_size_pretty(pg_relation_size('idx_users_email')) AS full_index,
  pg_size_pretty(pg_relation_size('idx_users_email_active')) AS partial_index;

On a table with 50M rows and 2M active rows, you will see something like 2145 MB versus 86 MB.

For query performance, use EXPLAIN (ANALYZE, BUFFERS, TIMING) before and after. Look for:

  • Filter: (deleted_at IS NULL) disappearing
  • Buffers: shared read= dropping toward zero
  • Execution time dropping from double-digit milliseconds to sub-millisecond

For cache pressure, monitor pg_stat_user_indexes over time. The idx_blks_hit versus idx_blks_read ratio on the partial index should be higher than the full index because the working set fits in memory.

Beyond soft deletes: status columns and time ranges

Soft deletes are the most common case, but partial indexes work anywhere your queries repeatedly filter on the same predicate.

Status columns. An orders table with status = 'pending', 'shipped', 'delivered' will have queries that always look for pending orders. A partial index on WHERE status = 'pending' stays tiny even after millions of completed orders accumulate.

Time ranges. A notifications table where the application only queries rows from the last 30 days can use:

CREATE INDEX idx_notifications_user_recent
ON notifications (user_id, created_at)
WHERE created_at > now() - interval '30 days';

This is not a perfect filter (the predicate uses a volatile function, so the index only contains rows that satisfied the condition at creation time), but for append-only time-series data it is often good enough. For stricter bounds, combine it with table partitioning on created_at and use the partial index inside each partition.

Boolean flags. An is_published flag on an articles table, an is_verified flag on a users table, or an is_archived flag on a projects table all create the same pattern: a small active set and a large historical set. If your queries always include AND is_archived = false, the partial index belongs there.

The pattern is always the same: identify the filter that appears in every hot query, move it into the index predicate, and verify that the planner agrees.

When the planner still says no: a debug checklist

Sometimes you build the partial index, run EXPLAIN, and Postgres still scans the full index or the table. Here is the checklist:

  1. Run ANALYZE on the table. Partial indexes rely on up-to-date statistics. If the planner thinks the table is tiny because autovacuum has not run, it may prefer a seq scan. Force stats with ANALYZE users;.
  2. Check for implicit casts. If the query compares a text column against a varchar parameter, or an integer against a bigint, the planner may see the predicate as a function call and refuse the match. Make the types line up.
  3. Look for OR conditions. WHERE deleted_at IS NULL OR is_admin = true cannot use a partial index on deleted_at IS NULL because the index does not contain rows where is_admin = true but deleted_at is set. Rewrite to a UNION or accept the full scan.
  4. Verify the index is valid. A failed CREATE INDEX CONCURRENTLY can leave an invalid index that the planner ignores. Check pg_index for indisvalid = false.
  5. Check for enable_indexscan = off or custom planner settings. Some monitoring tools or ORMs disable index scans for testing. Reset with SET enable_indexscan = on;.

The write-amplification trade-off

Partial indexes are not free. Every UPDATE or DELETE that touches the indexed columns must maintain the index. But here is the key: partial indexes maintain fewer entries. An update to a deleted row does not touch the partial index at all because the row is not in it. An update to an active row touches both the full index and the partial index, but the full index was already being maintained.

The real cost is the extra index on INSERT. When you insert a new active row, Postgres must write to both indexes. On high-throughput insert workloads (logging, events, telemetry), adding a partial index on a hot table can add 10-20% write latency. If your table is insert-heavy and query-light, a partial index may not pay for itself.

The rule: partial indexes are for tables where reads dominate, where a clear subset of rows is queried repeatedly, and where the query predicate is stable.

Composite partial indexes

Single-column partial indexes are the intro. The production version is usually composite:

CREATE INDEX idx_orders_user_status_created
ON orders (user_id, created_at)
WHERE status = 'pending';

This index is tiny (only pending orders) and perfectly answers:

SELECT * FROM orders
WHERE user_id = 123
  AND status = 'pending'
ORDER BY created_at DESC;

The planner will use it for index-only scans if the selected columns are in the index or the table is visible enough. Even with heap fetches, the scan is bounded to the small pending set.

Dropping the old index

Do not leave the full index in place after the partial index is working. Postgres will happily maintain both, and the query planner may even pick the full index if its statistics are slightly off. Verify with EXPLAIN that the partial index is chosen, then:

DROP INDEX idx_users_email;

This frees disk space and removes write amplification. Keep the partial index.

Summary

Partial indexes are the closest thing Postgres has to a “view over an index.” They are small, fast, and require no application changes. The only requirement is honesty about what your application actually queries.

If your table has soft deletes, status flags, or archival columns, and your queries always filter on them, a partial index is almost always the right answer. Build it, verify it with EXPLAIN (ANALYZE, BUFFERS), drop the bloated full index, and move on.

A note from Yojji

The kind of database optimization work that turns a routine lookup into a sub-millisecond query (partial indexes, planner verification, and honest predicate analysis) is exactly the kind of backend engineering Yojji’s teams build into the systems they ship for clients.