Rider-Grade .NET Debugging, Right Inside VS Code and Cursor (Sponsored)
No need to leave your code editor to debug C# code β ReSharper 2026.2 now brings Rider's debugger to VS Code, Cursor, and other compatible editors. It offers essential features such as breakpoints, watches, step controls, launch and attach options, unit-test debugging, and seamless support for your existing launch.json configurations.
No existing launch.json configurations? No problem. ReSharper will generate one for you!
A slow SQL query is one of the easiest ways to ruin a fast application.
You can have a clean architecture, a great cache, and a powerful server - and still watch a page hang because one query scans a million rows without an index.
Over the years, I have optimized hundreds of SQL queries across many production systems.
Most of the wins come from the same small set of techniques, applied again and again.
Some of them are obvious.
Some of them go against advice you have probably heard.
To make them easy to remember, I have grouped 20 proven techniques into 6 themes you can reach for whenever a query gets slow.
In this post, we will explore:
Write index-friendly (sargable) queries
Use indexes wisely
Avoid functions on columns in WHERE
Avoid leading wildcards in LIKE
Match data types exactly
Fetch only the data you need
Stop using SELECT *
Filter early
Paginate with Keyset instead of OFFSET
Query only what changed
Simplify joins and subqueries
Reduce JOIN complexity
Choose the right JOIN type
Replace redundant subqueries with JOINs or CTEs
Prefer EXISTS over IN
Design your schema for reads
Normalize wisely
Use materialized views
Make writes and transactions efficient
Batch large operations
Keep transactions short
Let the database help you
Read the execution plan
Keep statistics up to date
Use query hints sparingly
Monitor and tune continuously
Let's dive in.
All queries in this post were tested on the PostgreSQL database. The same ideas apply to other databases, though the exact syntax may differ.
An index only helps if your query lets the database use it.
A "sargable" query (from Search ARGument ABLE) is one where the database can jump straight to the rows it needs through an index, instead of scanning the whole table.
The four techniques below keep your queries sargable.
Indexes are the single biggest lever for read performance.
An index is a sorted data structure that lets the database find rows without scanning the entire table, much like the index at the back of a book saves you from reading every page.
Create indexes on the columns you filter, join, sort, and group by most often - the columns in your WHERE, JOIN, ORDER BY, and GROUP BY clauses.
When several columns are used together, a single composite index that covers them is far better than separate single-column indexes:
sql
1-- A composite index for frequent filters on status and order date2CREATEINDEX idx_orders_status_order_date
3ON orders (status, order_date);
This index speeds up queries that filter on status, and on status together with order_date.
Column order matters: an index on (status, order_date) helps queries that filter by status first, but not queries that filter by order_date alone.
You can also create a covering index that stores extra column values inside the index.
This helps when a query only needs columns available in the index, so the database can avoid reading the actual table rows.
It is especially useful for small, frequent lookup queries.
sql
1-- Cover a common read query without fetching rows from the table2CREATEINDEX idx_orders_status_order_date_covering
3ON orders (status, order_date)4 INCLUDE (customer_id, total_amount);56SELECT customer_id, total_amount
7FROM orders
8WHEREstatus='paid'9AND order_date >=DATE'2026-01-01';
Note: indexes are not free. Every index must be updated on each insert, update, and delete, and it takes disk space. Index the columns your queries actually use, not every column.
Wrapping a column in a function is one of the most common ways to accidentally disable an index.
When you call a function on a column, the database has to compute that function for every row before it can compare it, so it cannot use the index on the raw column:
sql
1-- Bad: the function on order_date prevents an index scan2SELECT*FROM orders
3WHERE EXTRACT(YEARFROM order_date)=2025;
Rewrite the condition to compare the raw column against a range. This keeps the query sargable and lets the index do its job:
sql
1-- Good: a sargable range over the raw column2SELECT*FROM orders
3WHERE order_date >='2025-01-01'4AND order_date <'2026-01-01';
Both queries return the same rows, but only the second can use an index on order_date.
The same rule applies to LOWER(email), CAST(...), and arithmetic on a column.
If you often need to filter on a computed value, create an expression index for that exact expression instead.
A LIKE pattern that starts with a wildcard cannot use a normal index.
The database reads an index from left to right, so it needs to know the beginning of the value.
A pattern like '%son' hides the beginning and forces a full scan:
sql
1-- Bad: leading wildcard forces a full table scan2SELECT*FROM customers
3WHERE last_name LIKE'%son';45-- Good: a known prefix allows an index range scan6SELECT*FROM customers
7WHERE last_name LIKE'Anders%';
If you genuinely need to search inside text - a "contains" search - a normal B-tree index will not help.
Reach for full-text search or a trigram index (the pg_trgm extension in PostgreSQL) built for that job.
Comparing two different data types forces the database to convert one of them, and that conversion can silently disable an index.
If a column is an integer but you compare it to a string, or you join an integer key to a bigint key, the database adds an implicit cast - and the index on the original column may be skipped.
Keep the types identical on both sides of every join and filter:
The fastest data to process is the data you never read.
Every column and every row you pull costs disk I/O, memory, and network time. These four techniques shrink the result set as early and as much as possible.
Note: in most cases you do not have to place these filters by hand.
A modern cost-based planner already pushes predicates down - it applies your WHERE conditions as early as it can and reorders joins on its own.
Rewriting the textual order of a WHERE clause rarely changes the plan.
What actually helps is giving the planner a selective, sargable filter and an index to apply it with.
So focus on making the filter index-friendly, not on where you type it.
Every join is extra work. The fewer tables the database has to match up, the faster the query.
A common mistake is joining a table you never actually read from:
sql
1-- Bad: joins suppliers but never uses it2SELECT p.product_name, c.category_name
3FROM products p
4JOIN categories c ON p.category_id = c.category_id
5JOIN suppliers s ON p.supplier_id = s.supplier_id;
Drop the join you do not need, and the database does less work for the same result:
sql
1-- Good: only the joins that contribute to the output2SELECT p.product_name, c.category_name
3FROM products p
4JOIN categories c ON p.category_id = c.category_id;
Read your SELECT and WHERE lists and remove any joined tables that are not referenced in either list.
The join type changes both the result and the cost.
Use an INNER JOIN when you need matching rows from both sides, a LEFT JOIN only when you genuinely need the unmatched rows too, and EXISTS when you just need to know whether a match exists:
sql
1-- Just checking existence: EXISTS stops at the first match2SELECT c.customer_id, c.name
3FROM customers c
4WHEREEXISTS(5SELECT1FROM orders o
6WHERE o.customer_id = c.customer_id
7AND o.total >1008);
A LEFT JOIN is used where an INNER JOIN would do - forces the database to carry unmatched rows, which it will only discard later.
11. Replace Redundant Subqueries with JOINs or CTEs
A correlated subquery runs once per row, which can be brutal on a large result set.
Here, the subquery fires for every order, just to look up a customer name:
sql
1-- Bad: a subquery executed per row2SELECT o.order_id,3(SELECT c.name FROM customers c
4WHERE c.customer_id = o.customer_id)AS customer_name
5FROM orders o;
A single join does the same work once, in one pass:
sql
1-- Good: one join instead of a per-row lookup2SELECT o.order_id, c.name AS customer_name
3FROM orders o
4JOIN customers c ON o.customer_id = c.customer_id;
When the same subquery is needed multiple times in a single statement, lift it into a common table expression (CTE) with WITH so it is written once and is easier to read.
Explore the slow query example:
sql
1-- Bad: the same aggregate subquery is repeated2SELECT3 c.customer_id,4 c.name,5(6SELECTSUM(o.total_amount)7FROM orders o
8WHERE o.customer_id = c.customer_id
9AND o.order_date >=DATE'2026-01-01'10)AS total_spent,11(12SELECTCOUNT(*)13FROM orders o
14WHERE o.customer_id = c.customer_id
15AND o.order_date >=DATE'2026-01-01'16)AS order_count
17FROM customers c;
And here is a faster query with CTA:
sql
1-- Good: calculate the result once and reuse it2WITH customer_order_totals AS(3SELECT4 customer_id,5SUM(total_amount)AS total_spent,6COUNT(*)AS order_count
7FROM orders
8WHERE order_date >=DATE'2026-01-01'9GROUPBY customer_id
10)11SELECT12 c.customer_id,13 c.name,14COALESCE(t.total_spent,0)AS total_spent,15COALESCE(t.order_count,0)AS order_count
16FROM customers c
17LEFTJOIN customer_order_totals t ON t.customer_id = c.customer_id;
EXISTS can short-circuit: it stops at the first matching row, while IN may build the full list of values first.
sql
1-- Existence check that stops at the first hit2SELECT p.product_id, p.product_name
3FROM products p
4WHEREEXISTS(5SELECT1FROM order_details od
6WHERE od.product_id = p.product_id
7);
Note: do not treat "EXISTS always beats IN" as a hard rule.
Modern PostgreSQL often rewrites IN, EXISTS, and even some joins into the same execution plan, so the two can perform identically.
The case that still bites is NOT IN with a subquery that can return NULL - it produces surprising results and a worse plan, so prefer NOT EXISTS there.
As always, check the plan instead of assuming.
Normalization keeps your data clean and consistent, and it is the right default.
But fully normalized data can be slow to read when a hot query has to join and aggregate the same tables on every request.
For read-heavy paths, it is fine to denormalize: pre-compute a summary and store it.
sql
1-- Pre-aggregate total sold per product into a summary table2CREATETABLE sales_summary AS3SELECT product_id,SUM(quantity)AS total_sold
4FROM order_details
5GROUPBY product_id;
Now the read is a simple lookup rather than a live aggregation across the entire order_details table.
The trade-off is that you must keep the summary in sync - refresh it on a schedule, or when the source data changes.
Denormalize on purpose, for specific hot queries, not everywhere.
A materialized view stores the result of a query physically, so reads hit pre-computed rows instead of recalculating them.
It is the built-in version of the summary table above, and PostgreSQL supports it directly:
sql
1-- Store a pre-computed aggregation2CREATE MATERIALIZED VIEW mv_total_sales AS3SELECT product_id,SUM(quantity)AS total_qty
4FROM order_details
5GROUPBY product_id;67-- A unique index lets the view refresh without locking readers8CREATEUNIQUEINDEX idx_mv_total_sales_product
9ON mv_total_sales (product_id);1011-- Refresh on a schedule; readers keep the old data until it is complete12REFRESH MATERIALIZED VIEW CONCURRENTLY mv_total_sales;
Queries against mv_total_sales are fast because the aggregation has already run.
The data is only as fresh as your last refresh, so use materialized views for expensive aggregations that can tolerate slight staleness - dashboards, reports, and leaderboards.
To learn more, see Get Started with Database Views in SQL.
Running one statement per row floods the database with round-trips and transaction overhead.
But a single statement that touches millions of rows is also a problem - it holds locks for a long time and can blow up your write-ahead log.
The middle ground is batching: process a fixed chunk at a time.
This statement moves rows into an archive table 1,000 at a time, deleting each chunk as it copies it:
sql
1-- Move rows in batches of 1000 until none are left2WITH batch AS(3DELETEFROM source_table
4WHERE ctid IN(5SELECT ctid
6FROM source_table
7WHERE processed =false8LIMIT10009)10RETURNING col1, col2
11)12INSERTINTO archive_table (col1, col2)13SELECT col1, col2 FROM batch;
Run this in a loop until it affects zero rows.
Each batch commits quickly, holds few locks, and keeps the system responsive while the bulk job runs.
A transaction holds its locks until it commits, and every other query that needs those rows has to wait.
The longer a transaction stays open, the more contention it creates.
Keep the transaction around the writes only, and do slow work - API calls, file I/O, heavy computation - outside it:
The execution plan is the database telling you exactly how it will run your query.
Before optimizing anything, look at the plan. In PostgreSQL, EXPLAIN ANALYZE runs the query and reports what really happened:
sql
1EXPLAINANALYZE2SELECT*FROM orders WHERE total >100;
Read it for the expensive operations: a Seq Scan on a big table usually means a missing or unused index, and a large gap between estimated and actual rows points to stale statistics.
The plan turns optimization from guesswork into evidence. You fix what the plan shows is slow, without assumptions.
The planner chooses between a scan and an index seek based on statistics about your data - how many rows there are, how many distinct values, and how they are spread out.
When those statistics are stale, the planner makes bad guesses and picks bad plans, even with perfect indexes.
ANALYZE refreshes them:
sql
1-- Refresh planner statistics for a table2ANALYZE orders;
PostgreSQL runs autovacuum to keep statistics current, but after a large bulk load or a mass update, it is worth running ANALYZE yourself.
This is the other half of the optimizer story: the engine can only optimize as well as its statistics let it.
Good stats matter far more than the exact wording of your query.
A query hint forces the database to run a query your way rather than the planner's.
PostgreSQL deliberately ships without hint syntax.
Its philosophy is that you should fix the root cause - indexes, statistics, query shape - rather than override the planner.
You can nudge it with session settings, but treat this as a diagnostic only in the development environment:
sql
1-- Diagnostic only: see what the plan looks like without a sequential scan2SET enable_seqscan =off;
If you truly need hints, the pg_hint_plan extension adds them - but reach for it last, after indexes and statistics have failed you.
A hint freezes a decision that is right today and may be wrong after your data grows, so it quietly becomes tomorrow's slow query.
Optimization is not a one-time task. Data grows, access patterns shift, and yesterday's fast query becomes today's bottleneck.
Track which queries actually cost the most. The pg_stat_statements extension aggregates execution stats across your whole workload:
sql
1-- Find the slowest queries by average execution time2SELECT query,3 calls,4 mean_exec_time
5FROM pg_stat_statements
6ORDERBY mean_exec_time DESC7LIMIT10;
Let this list tell you what to optimize next.
And this is where all the techniques in this post come together.
A modern planner already does the clever rewrites - predicate pushdown, join reordering, turning IN into a join.
You do not earn much by hand-tuning the text of a WHERE clause.
The big wins come from giving the planner what it needs: sargable predicates, fresh statistics, and the right indexes - then measuring to confirm.
Optimizing SQL queries is part art, but it is definitely not rocket science.
And it always pays off.
Let's recap the key takeaways:
Keep queries sargable. Index the columns you filter, join, and sort on, and never hide them behind a function, a leading wildcard, or a type mismatch - that is what lets an index do its job.
Touch less data. Skip SELECT *, filter to the rows you need, paginate by key instead of a deep OFFSET, and read only what changed.
Simplify the shape. Drop joins you do not use, pick the right join type, and replace per-row subqueries with joins or CTEs.
Design for reads. Denormalize hot paths on purpose, and pre-compute heavy aggregations with materialized views.
Keep writes lean. Batch large operations and keep transactions short to avoid lock contention.
Trust the optimizer, then verify. Read the execution plan, keep statistics fresh, avoid hints, and let pg_stat_statements point you at the real bottleneck.
Before any optimization, remember: Measure first.
Often, you will find the query is already fast enough, and the best optimization is the one you did not need to make.
Hope you find this newsletter useful. See you next time.
Whenever you're ready, here's how I can help you:
The .NET Senior Playbook is built to:
Fast-track you from junior or mid-level to senior
Keep you growing as a senior
Help you beat any .NET interview
Covers everything: C#, ASP.NET Core, EF Core, system design β answer each question first, reveal the solution, and a test after every chapter proves it stuck. Finish, and you earn a verifiable certificate for your LinkedIn.
Not sure where you stand? Take the free .NET Interview Run:
Find out your real level β Junior to Senior+
A realistic mock .NET interview β across 13 areas of C#, .NET, ASP.NET Core and System Design
No credit card required. When you finish, you get a personalized report: your level, your strongest and weakest areas, and where to focus next β the perfect way to benchmark yourself before diving into the Playbook.