Problem Context
SQL is 50 years old in 2026 and still the highest-leverage skill in the data stack. Every NoSQL contender that promised to kill it โ MongoDB, DynamoDB, Cassandra โ eventually shipped a SQL-ish query layer (Mongo aggregation pipelines, PartiQL, CQL). The reason is boring and durable: declarative set-based logic is genuinely a better way to express most data operations than imperative loops.
Yet most application code is written by engineers who know just enough SQL to get a JOIN wrong. They use SELECT *, N+1 in ORMs, write correlated subqueries that scan a table per row, and reach for stored procedures because they don't know window functions exist. This guide is the patterns that separate "working" SQL from production SQL.
- Your ORM emits N+1 queries and you discovered it in production at 3am
- You don't know when to use
EXISTSvsINvsJOIN - You wrote a 200-line stored procedure that one window function would replace
- You can't read an execution plan
Six SQL patterns cover 80% of real-world queries. Get them right and you rarely need raw procedural code.
Concept Explanation
Think in relations: tables and query results are unordered sets of rows. SQL is the language for transforming sets:
- FROM / JOIN โ combine sets (cartesian product, then filter).
- WHERE โ filter rows.
- GROUP BY / HAVING โ collapse rows into buckets, then filter buckets.
- SELECT โ project columns (and compute aggregates / window functions).
- ORDER BY / LIMIT โ finally impose ordering for the consumer.
The optimizer reorders all of this; you describe what, it picks how. The execution plan is your contract with it.
flowchart LR
F["FROM / JOIN<br/>(combine)"] --> W["WHERE<br/>(filter rows)"]
W --> G["GROUP BY<br/>(bucket)"]
G --> H["HAVING<br/>(filter buckets)"]
H --> S["SELECT<br/>(project + window)"]
S --> O["ORDER BY / LIMIT<br/>(present)"]
style F fill:#0078D4,color:#fff,stroke:#005a9e
style O fill:#16a34a,color:#fff,stroke:#15803d
Implementation
Step 1: Solve N+1 with a single JOIN or LATERAL
-- Bad: ORM loops, one query per author
-- SELECT * FROM authors;
-- foreach author: SELECT * FROM books WHERE author_id = ?
-- Good: one round trip
SELECT a.id, a.name, b.id AS book_id, b.title
FROM authors a
LEFT JOIN books b ON b.author_id = a.id
ORDER BY a.id;
-- Better when you only need the latest N per author
SELECT a.id, a.name, b.title, b.published_at
FROM authors a
JOIN LATERAL (
SELECT title, published_at FROM books
WHERE author_id = a.id
ORDER BY published_at DESC
LIMIT 3
) b ON true;Step 2: EXISTS for membership, JOIN for projection
-- "Customers who placed an order in 2026"
SELECT c.id, c.name
FROM customers c
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.customer_id = c.id AND o.created_at >= '2026-01-01'
);
-- IN with a subquery is OK but EXISTS short-circuits and avoids dedup
-- JOIN would duplicate customers per matching order โ wrong shape.Step 3: Window functions instead of self-joins
-- Running total per customer
SELECT customer_id, created_at, total_cents,
SUM(total_cents) OVER (
PARTITION BY customer_id
ORDER BY created_at
ROWS UNBOUNDED PRECEDING
) AS lifetime_total
FROM orders;
-- Top N per group
SELECT * FROM (
SELECT *,
RANK() OVER (PARTITION BY category_id ORDER BY price DESC) AS r
FROM products
) t WHERE r <= 5;Step 4: Upsert (MERGE in 2026 is finally portable)
-- ANSI MERGE works in SQL Server, Postgres 15+, Oracle, Snowflake, BigQuery
MERGE INTO inventory AS t
USING (VALUES ('SKU-1', 10), ('SKU-2', 5)) AS s(sku, qty)
ON t.sku = s.sku
WHEN MATCHED THEN UPDATE SET qty = t.qty + s.qty
WHEN NOT MATCHED THEN INSERT (sku, qty) VALUES (s.sku, s.qty);
-- Postgres-flavored alternative (older, still common)
INSERT INTO inventory (sku, qty) VALUES ('SKU-1', 10)
ON CONFLICT (sku) DO UPDATE SET qty = inventory.qty + EXCLUDED.qty;Step 5: Parameterize from C# (Dapper, no ORM tax)
using Dapper;
using Npgsql;
await using var conn = new NpgsqlConnection(connStr);
// Always parameterize โ never string-concatenate user input
var orders = await conn.QueryAsync<Order>(
"""
SELECT id, customer_id, total_cents, created_at
FROM orders
WHERE customer_id = @CustomerId AND created_at >= @Since
ORDER BY created_at DESC
LIMIT @Take
""",
new { CustomerId = customerId, Since = since, Take = 50 });Step 6: Read the plan
-- Postgres
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
-- SQL Server
SET STATISTICS IO, TIME ON; SELECT ...;
-- Look for:
-- "Seq Scan" / "Table Scan" โ missing index or unused index
-- "Hash Join" on small + huge โ reverse the join order if possible
-- "Nested Loop" on large rows โ usually you want hash or merge join
-- "Sort" โ consider an index that already ordersCommon Pitfalls
- SELECT * everywhere. Wastes network, defeats covering indexes, and breaks when columns are added. Project explicit columns.
- Functions on indexed columns.
WHERE LOWER(email) = 'x'kills the index. Either store normalized data or create a functional index:CREATE INDEX ON users (LOWER(email)). - Implicit type coercion.
WHERE id = '42'on a bigint forces a cast on every row. Match types exactly. - OR across columns.
WHERE a = 1 OR b = 2often defeats indexes. Rewrite asUNION ALLof two single-column lookups. - NULL semantics surprises.
NULL = NULLis unknown, not true. UseIS NULL, and rememberNOT INwith a NULL in the list returns no rows. - Trusting the ORM's SQL. Always log generated queries in dev. EF Core, Hibernate, Sequelize all emit pathological SQL when given the chance โ eager loading, projection mistakes, polymorphic queries.
Practical Takeaways
- Think in sets, not loops. If you're writing a cursor or app-side loop, ask whether one query would do it.
- EXISTS for "does any match exist", JOIN for "give me the matching rows".
- Window functions replace 90% of self-joins and 100% of running-total subqueries.
- MERGE / ON CONFLICT is the canonical upsert in 2026 โ works in Postgres 15+, SQL Server, Snowflake, BigQuery.
- Parameterize everything; never concatenate strings into SQL.
- Read the execution plan before tuning. EXPLAIN ANALYZE is the only honest profiler.
- Log every query your ORM emits in dev. The N+1 you find is the production incident you avoid.

