SQL
Last reviewed: 2026-09-17
Purpose: SQL querying and management patterns β paged stored procedures, join strategies, string manipulation, and table creation with sensible indexing, using Microsoft SQL Server (T-SQL) for the examples.
Table of Contents
- Overview
- Stored Procedures
- Joins
- String Operations
- Table Creation & Indexing
- Query Hygiene
- Topics Covered
Overview
Most SQL work falls into a handful of recurring shapes: reading a page of a large result set, combining tables, reshaping strings, and creating tables that queries can use efficiently. The patterns below are portable in outline to PostgreSQL, MySQL and SQLite; syntax notes call out where T-SQL differs.
Stored Procedures
Paged results with OFFSET / FETCH
Keyset-free paging is the quickest way to expose a large table through an API. OFFSET/FETCH requires an ORDER BY, and a total count is normally returned alongside the page so the caller can render pagination controls.
CREATE OR ALTER PROCEDURE dbo.GetEventsPaged
@PageNumber INT = 1,
@PageSize INT = 50,
@TotalRows INT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SELECT @TotalRows = COUNT(*) FROM dbo.Event;
SELECT
e.EventId,
e.FeatureName,
e.HostName,
e.EventTime
FROM dbo.Event AS e
ORDER BY e.EventTime DESC
OFFSET (@PageNumber - 1) * @PageSize ROWS
FETCH NEXT @PageSize ROWS ONLY;
END;
DECLARE @total INT;
EXEC dbo.GetEventsPaged @PageNumber = 2, @PageSize = 25, @TotalRows = @total OUTPUT;
SELECT @total AS TotalRows;
Guard the parameters β a negative page number or an unbounded page size turns a friendly endpoint into a full table scan:
IF @PageNumber < 1 SET @PageNumber = 1;
IF @PageSize < 1 SET @PageSize = 25;
IF @PageSize > 500 SET @PageSize = 500;
Notes on paging performance
| Approach | Behaviour |
|---|---|
| OFFSET / FETCH | Simple; cost grows with the offset because skipped rows are still read |
| Keyset (seek) paging | WHERE (EventTime, EventId) < (@lastTime, @lastId) β constant cost per page, needs a matching index |
| ROW_NUMBER() | Portable to older engines (and to PostgreSQL/MySQL 5.x) but requires a sort of the full set |
Keyset paging is the right answer for deep pagination over large tables; OFFSET is fine for the first few pages.
Dynamic filtering
When optional filters make a large permutation of predicates, build the filter once and reuse it, rather than writing a procedure per combination:
DECLARE @sql NVARCHAR(MAX) = N'
SELECT TOP (@PageSize) EventId, FeatureName, HostName, EventTime
FROM dbo.Event
WHERE 1 = 1';
IF @FeatureName IS NOT NULL SET @sql += N' AND FeatureName = @FeatureName';
IF @FromTime IS NOT NULL SET @sql += N' AND EventTime >= @FromTime';
SET @sql += N' ORDER BY EventTime DESC';
EXEC sp_executesql @sql,
N'@PageSize INT, @FeatureName NVARCHAR(100), @FromTime DATETIME2',
@PageSize, @FeatureName, @FromTime;
Always pass values as parameters to sp_executesql β string-concatenated values are the classic injection hole.
Joins
| Join | Returns |
|---|---|
| INNER JOIN | Only rows matching on both sides |
| LEFT JOIN | All left rows, NULLs where the right side has no match |
| RIGHT JOIN | Mirror of LEFT (rarely used β prefer reordering) |
| FULL OUTER JOIN | All rows from both sides, NULLs on either side |
| CROSS JOIN | Cartesian product β useful for generating combinations or date spines |
-- Rows with their parent metadata, keeping rows that have no parent
SELECT
u.FeatureName,
u.HostName,
m.OwnerTeam
FROM dbo.UsageEvent AS u
LEFT JOIN dbo.FeatureMeta AS m
ON m.FeatureName = u.FeatureName
WHERE u.EventTime >= '2026-01-01';
-- Rows that are missing their parent β the anti-join pattern
SELECT u.*
FROM dbo.UsageEvent AS u
LEFT JOIN dbo.FeatureMeta AS m ON m.FeatureName = u.FeatureName
WHERE m.FeatureName IS NULL;
NOT EXISTS is usually the clearer and often faster way to express the anti-join:
SELECT u.*
FROM dbo.UsageEvent AS u
WHERE NOT EXISTS (
SELECT 1 FROM dbo.FeatureMeta AS m WHERE m.FeatureName = u.FeatureName
);
Filtering a LEFT JOIN's right-hand table in the WHERE clause silently converts it into an INNER JOIN β put such predicates in the ON clause when the outer rows must survive.
String Operations
Modern T-SQL provides STRING_SPLIT for the common case of decomposing a delimited column:
Because STRING_SPLIT returns a set rather than a value, use STRING_AGG to reverse the operation:
SELECT STRING_AGG(value, ',') WITHIN GROUP (ORDER BY value) AS Rejoined
FROM STRING_SPLIT('alpha,beta,gamma', ',');
Where a positional value is needed from a delimited string, a small inline function avoids repeated CHARINDEX/SUBSTRING arithmetic:
CREATE OR ALTER FUNCTION dbo.SplitPart
(@Input NVARCHAR(4000), @Delimiter NCHAR(1), @Part INT)
RETURNS NVARCHAR(4000)
AS
BEGIN
DECLARE @pos INT = 1, @idx INT = 1, @start INT = 1;
WHILE @idx < @Part
BEGIN
SET @pos = CHARINDEX(@Delimiter, @Input, @pos);
IF @pos = 0 RETURN NULL;
SET @pos += 1; SET @idx += 1;
END;
DECLARE @end INT = CHARINDEX(@Delimiter, @Input, @pos);
RETURN SUBSTRING(@Input, @pos, CASE WHEN @end = 0 THEN 4000 ELSE @end - @pos END);
END;
On PostgreSQL the equivalent is string_to_array(), split_part() and regexp_split_to_table(); on MySQL 8, JSON_TABLE or a recursive CTE. Trailing and leading whitespace causes more join mismatches than casing β apply TRIM() on both sides of a comparison where data quality is uncertain.
Table Creation & Indexing
CREATE TABLE dbo.UsageEvent (
EventId BIGINT IDENTITY(1,1) NOT NULL,
FeatureName NVARCHAR(100) NOT NULL,
HostName NVARCHAR(200) NOT NULL,
UserName NVARCHAR(100) NULL,
LicCount FLOAT NOT NULL DEFAULT 1,
DurationSecs FLOAT NULL,
EventTime DATETIME2(3) NOT NULL,
CONSTRAINT PK_UsageEvent PRIMARY KEY CLUSTERED (EventId),
CONSTRAINT UQ_UsageEvent UNIQUE (FeatureName, HostName, EventTime)
);
-- Index the columns used in the WHERE and ORDER BY clauses
CREATE INDEX IX_UsageEvent_EventTime
ON dbo.UsageEvent (EventTime DESC)
INCLUDE (FeatureName, HostName, LicCount);
| Guideline | Reason |
|---|---|
| Cluster on the narrowest ever-increasing key | Keeps inserts sequential and pages dense |
| Index foreign-key columns explicitly | SQL Server does not create them automatically |
| Cover frequent queries with INCLUDE | Lets the engine answer without key lookups |
| Keep indexes narrow | Wide keys bloat every non-clustered index |
| Partition huge append-only tables | Enables cheap archiving by dropping partitions |
Query Hygiene
- Filter with
SARGablepredicates βWHERE EventTime >= @duses an index;WHERE CAST(EventTime AS DATE) = @dusually does not. - Select only the columns needed;
SELECT *breaks covering indexes and inflates network cost. - Prefer
EXISTSoverINfor subqueries against large tables. - Use
SET NOCOUNT ONin procedures to suppress per-statement row counts. - Check the execution plan for scans over large tables β the missing-index hint is usually the fastest fix available.
Topics Covered
- Paged stored procedures with
OFFSET/FETCH, output parameters and keyset alternatives - Dynamic, parameterised filtering with
sp_executesql - Join types, anti-joins and the
LEFT JOIN-predicate pitfall - String splitting and rejoining with
STRING_SPLITandSTRING_AGG - Table creation with primary keys, constraints and covering indexes
- Query hygiene: SARGable predicates, plan review and column projection