Blog

  • SQL Server Index Syntax: Covering Indexes, INCLUDE Columns, and Filtered Indexes

    SQL Server Index Syntax: Covering Indexes, INCLUDE Columns, and Filtered Indexes

    Beyond a basic index, two techniques do most of the real performance work: covering indexes, which eliminate the key lookup from the previous lesson entirely, and filtered indexes, which shrink an index down to just the rows that actually matter.

    Covering & filtered indexes(what actually lives in the index)one index row:KEY:customer_idsets sort order+INCLUDE:order_status, order_totaljust riding along, no sortcovers the whole queryno Key Lookup!Key Lookupfiltered index = only the rows that match WHERE:shippedshippedpendingcompletedpendingshippedIX_OrderLog_PendingOnlytiny index — only ‘pending’ rowsoptimizer only picksthis index when WHEREclause PROVES a match —otherwise: full scan 📌

    Covering Index with INCLUDE

    CREATE NONCLUSTERED INDEX IX_OrderLog_Customer_Covering
    ON dbo.OrderLog (customer_id)
    INCLUDE (order_status, order_total);
    
    -- Fully satisfied by the index — no key lookup needed
    SELECT customer_id, order_status, order_total
    FROM dbo.OrderLog
    WHERE customer_id = 42;

    “Covering” means every column the query needs — for filtering, sorting, or just selecting — exists somewhere in the index itself, so the engine never has to jump back to the clustered index at all. This directly eliminates the exact key-lookup cost the previous lesson demonstrated.

    Key Columns vs INCLUDE Columns

    Key columns Determine sort order Usable for seeking/filtering INCLUDE columns Just ride along at the leaf Avoid a lookup, can’t be used to seek

    Put columns you filter/sort on in the key; put columns you only ever SELECT in INCLUDE — this keeps the index narrower and cheaper to maintain than making everything a key column. Key columns also enforce sort order (relevant to ORDER BY), while INCLUDE columns carry no ordering guarantee at all — they’re purely along for the ride.

    Common mistake: Putting every SELECTed column into the key list “to be safe.” Wider key columns mean a physically larger B-tree, more page splits on insert, and more expensive maintenance on every write — INCLUDE exists specifically to avoid that cost for columns that only need to be read, never searched or sorted on.

    Filtered Index: Indexing Just a Subset

    CREATE NONCLUSTERED INDEX IX_OrderLog_PendingOnly
    ON dbo.OrderLog (order_date)
    WHERE order_status = 'pending';

    Ideal when queries consistently target a small, well-defined subset of a large table — the index is smaller, faster to scan, and cheaper to maintain since it only updates when a matching row changes (a row with order_status = 'completed' never touches this index at all, on insert or update).

    -- The optimizer only uses a filtered index when the query's WHERE clause
    -- provably matches (or is a subset of) the index's filter condition:
    SELECT * FROM dbo.OrderLog WHERE order_status = 'pending' AND order_date > '2026-01-01';
    -- Uses IX_OrderLog_PendingOnly — the query's filter is compatible with the index's
    
    SELECT * FROM dbo.OrderLog WHERE order_date > '2026-01-01';
    -- Does NOT use it — this query has no order_status filter, so the index can't
    -- guarantee it covers every matching row
    Practice tip: Build both indexes above, then compare sys.dm_db_index_physical_stats page counts between the filtered index and an unfiltered equivalent covering the same key column, on a table where ‘pending’ is a small fraction of total rows. The size difference makes the benefit concrete rather than theoretical.

    Enjoyed this?

    Subscribe to get every new SQL Server lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes, projects, and 10+ exercises per chapter? Check out SQL Server for Developers & DBAs, coming soon on this site.

  • 5 SQL Server DMVs Every DBA Should Know for Performance Tuning

    5 SQL Server DMVs Every DBA Should Know for Performance Tuning

    Extended Events (previous lesson) capture what happened over a time window you chose to record. Dynamic Management Views answer a different, often more urgent question: what is the state of the server right now, and what has it accumulated since the last restart — no trace setup required, just a SELECT.

    5 DMVs, sketched out(what each one uniquely answers)dm_exec_query_statspriciest queries,historically (all runs)dm_exec_requestswhat’s running NOW,+ blocking_session_iddm_os_wait_statswhat the WHOLE serveris waiting ondm_db_index_usage_statsis THIS index actuallybeing used?dm_exec_sessionswho’s connected rightnow, and from where?the cleanup signal — one index, two numbers:user_updates: 50kseeks+scans: 0costs on every write,helps zero reads 🗑️ DROP?these counters resetto ZERO on every servicerestart — always checkuptime first! 📌

    1. Top Queries by Logical Reads

    SELECT TOP 10
        qs.total_logical_reads / qs.execution_count AS avg_logical_reads,
        qs.execution_count,
        SUBSTRING(st.text, (qs.statement_start_offset/2)+1,
            ((CASE qs.statement_end_offset WHEN -1 THEN DATALENGTH(st.text) ELSE qs.statement_end_offset END - qs.statement_start_offset)/2)+1) AS query_text
    FROM sys.dm_exec_query_stats qs
    CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st
    ORDER BY avg_logical_reads DESC;

    This DMV accumulates statistics since the plan was cached, across every execution — which makes it fundamentally different from a single execution plan (Lesson 3): it answers “which query costs the most in aggregate,” not “why is this one specific run slow.”

    2. What’s Running Right Now

    SELECT session_id, status, command, wait_type, wait_time, blocking_session_id, total_elapsed_time
    FROM sys.dm_exec_requests
    WHERE session_id > 50; -- excludes internal system sessions

    A non-NULL blocking_session_id here is one of the most actionable single columns in this entire toolkit — it directly identifies which session is blocking which, the starting point for diagnosing the blocking scenarios covered fully in Chapter 9.

    3. What the Server Is Waiting On

    SELECT TOP 10 wait_type, wait_time_ms, waiting_tasks_count
    FROM sys.dm_os_wait_stats
    WHERE wait_type NOT LIKE '%SLEEP%'
    ORDER BY wait_time_ms DESC;

    This is server-wide, cumulative since the last restart or manual reset — a genuinely powerful “what’s the bottleneck category, in general” question. High PAGEIOLATCH_* waits point toward disk I/O pressure; high CXPACKET/CXCONSUMER points toward parallelism; high LCK_M_* points toward blocking. This single query is often the very first thing a DBA runs when investigating “the server feels slow.”

    4. Which Indexes Are Actually Used

    SELECT OBJECT_NAME(s.object_id) AS table_name, i.name AS index_name,
        s.user_seeks, s.user_scans, s.user_lookups, s.user_updates
    FROM sys.dm_db_index_usage_stats s
    JOIN sys.indexes i ON i.object_id = s.object_id AND i.index_id = s.index_id
    WHERE s.database_id = DB_ID();

    The Quick Reference

    DMV Answers
    sys.dm_exec_query_stats Which queries are most expensive, historically (aggregated across all executions)?
    sys.dm_exec_requests What’s running right now, and waiting on what?
    sys.dm_os_wait_stats What is the whole server spending time waiting on, cumulatively?
    sys.dm_db_index_usage_stats Is this specific index actually being used?
    sys.dm_exec_sessions Who’s connected right now, and from where?

    A high-value pattern: user_updates high but user_seeks + user_scans + user_lookups near zero identifies an index that costs on every write but never helps a read — a strong candidate to drop. This single comparison is one of the most reliably useful index-cleanup queries a DBA runs, because it’s the exact opposite of the covering-index tuning from Lesson 2: a genuinely wasted index, paid for on every INSERT/UPDATE, that no query ever benefits from.

    Common mistake: Treating sys.dm_db_index_usage_stats as permanent history. These counters reset to zero on every SQL Server service restart — a recently-restarted server can make a genuinely valuable index look “unused” simply because it hasn’t been queried yet since the restart. Always check server uptime before trusting a zero.
    Practice tip: Run query #4 against your own practice database, and find the index with the highest user_updates-to-usage ratio. Before actually dropping anything, cross-check it against sys.dm_exec_query_stats (query #1) to see if any expensive query might depend on it that simply hasn’t run recently — real index cleanup always needs more than one signal.

    Enjoyed this?

    Subscribe to get every new SQL Server lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes, projects, and 10+ exercises per chapter? Check out SQL Server for Developers & DBAs, coming soon on this site.

  • SQL Server Profiler and Extended Events: Capturing Slow Queries in Production

    SQL Server Profiler and Extended Events: Capturing Slow Queries in Production

    Reading one query’s execution plan (previous lesson) assumes you already know which query is slow. In production, that’s rarely true — you need to first discover what’s actually running and how long it takes, across potentially thousands of different queries hitting the server every minute. Two tools do this: one classic, one modern.

    Profiler vs Extended Events(where does the filtering happen?)Profiler — filters LATE, client-sideEnginemakes every eventfilterProfiler UIsees it AFTER fullcost already paidExtended Events — filters EARLY, server-sideEnginemakes every eventfilterstopped right awayTarget filecheap — most eventsnever fully materializean unfiltered Profiler traceon a busy prod server canITSELF become the slowdown 📌

    Setting Up a Basic Profiler Trace

    1. Open SSMS → Tools → SQL Server Profiler
    2. Connect, choose the TSQL_Duration template (or build a custom trace with RPC:Completed and SQL:BatchCompleted events)
    3. Add a column filter on Duration (e.g. > 500ms) to cut noise
    4. Run your workload, stop the trace, sort by Duration descending

    Profiler is genuinely the most approachable way to see this for the first time — a live, scrolling grid of every statement hitting the server, which query text, how long it took, who ran it. That approachability comes at a real cost, covered below.

    The Modern Equivalent: Extended Events

    CREATE EVENT SESSION SlowQueries ON SERVER
    ADD EVENT sqlserver.sql_statement_completed (
        ACTION (sqlserver.sql_text, sqlserver.database_name)
        WHERE duration > 500000 -- microseconds = 500ms
    )
    ADD TARGET package0.event_file (SET filename = N'SlowQueries');
    GO
    ALTER EVENT SESSION SlowQueries ON SERVER STATE = START;
    -- ... let it run, then:
    ALTER EVENT SESSION SlowQueries ON SERVER STATE = STOP;

    Notice the filter (WHERE duration > 500000) is applied at the engine level, before the event is even fully captured — this is the key architectural difference from Profiler, whose filtering happens client-side after every single event has already been generated and sent across.

    Why This Difference Actually Matters

    Profiler Approachable, good for a first look Extended Events Lower overhead — production choice

    Profiler’s client-side filtering means the server does the full work of generating every event regardless of whether you’ll actually look at it — on a busy production server, running Profiler can itself become a measurable performance problem, sometimes ironically worse than the slow queries you’re trying to diagnose. Extended Events’ server-side filtering means events that don’t match never get fully materialized at all, which is why Microsoft has deprecated Profiler in favor of XEvents for exactly this reason, and why production DBAs default to XEvents almost universally today.

    Common mistake: Running an unfiltered, wide-open Profiler trace against a busy production server “just to see what’s happening.” This is a genuinely risky move — always filter aggressively (duration, database, specific event types) and prefer Extended Events for anything beyond a quick local diagnostic session.
    Practice tip: Set up the Extended Events session above against your own local practice database, then deliberately run a slow query (e.g. one missing an index from earlier in this chapter) and confirm it gets captured in the event file. Query the results with sys.fn_xe_file_target_read_file to see the raw captured data rather than relying only on the GUI viewer.

    Enjoyed this?

    Subscribe to get every new SQL Server lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes, projects, and 10+ exercises per chapter? Check out SQL Server for Developers & DBAs, coming soon on this site.

  • Logins, Users, and Roles in SQL Server: The Principle of Least Privilege

    Logins, Users, and Roles in SQL Server: The Principle of Least Privilege

    Two layers, frequently conflated by beginners — getting this right is the foundation of every other security decision, including the service-account grants your Chapter 12 capstone will require you to design and justify.

    Least privilege, one keyring(hand out only the keys that fit)LOGINserver-level:can you connect?USERdatabase-level:what can you do here?app_service’s keyring — only what it needs:SELECTINSERT / UPDATEdb_ownerthe master key —opens everything. don’t.AuditLog tablerole GRANTs UPDATE…DENY UPDATE winsstill blocked, alwaysgrant exactly what’s needed —nothing wide, nothing “just in case”DENY always beats GRANT,no matter which rolehanded out the grant 📌

    Login vs User

    Login User
    Scope Server-level — can you connect at all? Database-level — what can you do here?
    Created with CREATE LOGIN CREATE USER ... FOR LOGIN

    This two-layer split has a practical consequence worth internalizing: a login can exist on the server with no matching user in a given database (meaning it can authenticate but can’t touch that database’s objects at all), and conversely a database can be moved or restored to a different server where the matching login doesn’t exist yet — producing an “orphaned user,” a genuinely common real-world migration gotcha fixed with ALTER USER ... WITH LOGIN =.

    Granting Access the Right Way

    CREATE LOGIN app_service WITH PASSWORD = 'Str0ng!PasswordHere#2024';
    CREATE USER app_service FOR LOGIN app_service;
    
    CREATE ROLE app_read_write;
    GRANT SELECT, INSERT, UPDATE ON SCHEMA::dbo TO app_read_write;
    ALTER ROLE app_read_write ADD MEMBER app_service;

    Granting permissions to a role, then adding users as members, is the pattern to default to over granting permissions to individual users directly. When ten application accounts all need the same access, you manage one role’s permission set instead of ten separate, potentially-drifting grants.

    DENY Always Wins

    DENY overrides GRANT, even from another role, regardless of role membership order

    -- Even though app_read_write GRANTs UPDATE, an explicit DENY on the same table wins:
    DENY UPDATE ON dbo.AuditLog TO app_read_write; -- audit logs should never be editable, even by the app
    
    -- app_service, a member of app_read_write, now genuinely cannot UPDATE AuditLog,
    -- despite the role's blanket GRANT UPDATE ON SCHEMA::dbo covering it

    This makes DENY the right tool for a deliberate, hard exception to a broader grant — exactly the AuditLog scenario above, where “the app can write to most tables” needs one specific, unbreakable carve-out.

    The Principle That Matters Most

    An application’s service account should almost never be db_owner. Grant exactly the permissions the application actually needs — usually db_datareader + db_datawriter built-in roles, or narrower, custom roles scoped to specific tables. A compromised connection with db_owner can drop every table, read every row, and grant itself further permissions; the same compromise with a narrowly-scoped role can only do what that role permits.

    Common mistake: Granting db_owner during development “just to get things working,” then never revisiting it before shipping. This is precisely the shortcut the Chapter 12 capstone’s security requirement is designed to catch — write out exactly which GRANTs a service account needs, and why, rather than reaching for the broadest role available.
    Practice tip: Run the auditing query SELECT * FROM sys.database_role_members joined to sys.database_principals twice on the same login above, to see role membership from both directions. Getting comfortable inspecting existing grants, not just creating new ones, is what real least-privilege maintenance looks like.

    Enjoyed this?

    Subscribe to get every new SQL Server lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes, projects, and 10+ exercises per chapter? Check out SQL Server for Developers & DBAs, coming soon on this site.

  • SQL Server Isolation Levels and Deadlocks: READ COMMITTED, SNAPSHOT, and Prevention

    SQL Server Isolation Levels and Deadlocks: READ COMMITTED, SNAPSHOT, and Prevention

    The “I” in ACID (previous lesson) said concurrent transactions don’t see each other’s uncommitted changes — but how much isolation, exactly, is itself a tunable setting with real tradeoffs. This lesson covers what each level actually permits, and how misunderstanding isolation is precisely how deadlocks catch people off guard.

    Isolation levels & the deadlock cyclestricter → more blockingREAD UNCOMMITTEDdirty reads allowed= NOLOCK hintREAD COMMITTEDthe defaultno dirty readsSERIALIZABLEstrictest, most blockingacts one-at-a-timeSNAPSHOTrow-versioning insteadreaders never block writersthen two sessions deadlock like this:Session 1HOLDS: 🔒 Account AWANTS: Account BSession 2HOLDS: 🔒 Account BWANTS: Account ADEADLOCK!💥chosen as VICTIM — error 1205, rolled backcommits successfullythe real fix: always lockresources in the SAME ordereverywhere — breaks the cycle 📌

    The Isolation Levels

    Level Notes
    READ UNCOMMITTED Fastest, allows dirty reads (seeing another transaction’s uncommitted, possibly-about-to-be-rolled-back changes) — rarely appropriate; this is what the SQL-hint WITH (NOLOCK) effectively opts a single query into
    READ COMMITTED (default) Never reads uncommitted data — SQL Server’s out-of-the-box behavior for every connection unless explicitly changed
    SERIALIZABLE Strictest, most blocking — behaves as if transactions ran one at a time, at real concurrency cost
    SNAPSHOT Row versioning — readers never block writers, and writers never block readers, at the cost of tempdb overhead for storing row versions
    -- Setting isolation level for a session
    SET TRANSACTION ISOLATION LEVEL SNAPSHOT;
    
    -- Must be enabled at the DATABASE level first, or SNAPSHOT requests are rejected
    ALTER DATABASE CURRENT SET ALLOW_SNAPSHOT_ISOLATION ON;

    How a Deadlock Forms

    Transaction 1: locks A Transaction 2: locks B T1 wants B — blocked T2 wants A — blocked

    Each transaction holds a lock the other needs — a circular wait. SQL Server automatically detects this and kills one transaction (the “deadlock victim,” chosen by lowest rollback cost by default, meaning the transaction that’s done the least work so far is usually the one sacrificed).

    -- Reproducing this exact scenario needs two sessions running simultaneously:
    -- Session 1:
    BEGIN TRANSACTION;
    UPDATE dbo.BankAccount SET balance = balance - 100 WHERE account_id = 1; -- locks account 1
    -- (pause here, run Session 2's first line, then continue)
    UPDATE dbo.BankAccount SET balance = balance + 100 WHERE account_id = 2; -- wants account 2, blocked
    
    -- Session 2 (run its first line while Session 1 is paused above):
    BEGIN TRANSACTION;
    UPDATE dbo.BankAccount SET balance = balance - 50 WHERE account_id = 2; -- locks account 2
    UPDATE dbo.BankAccount SET balance = balance + 50 WHERE account_id = 1; -- wants account 1 → deadlock

    The Real Fix

    Always access shared tables/rows in the same consistent order across every transaction in your application (e.g. always update the lower account_id first). If every transaction acquires locks in the same sequence, the circular-wait condition can’t form — in the reproduction above, if both sessions updated account 1 before account 2 every time, neither would ever end up waiting on a lock the other already held while itself holding something the other needed.

    Common mistake: Treating deadlocks as a database bug to “fix” with more indexes or a bigger server. Deadlocks are fundamentally an application logic problem — the fix lives in the order your code acquires locks, not in the database’s configuration or hardware.
    Practice tip: If you have two query windows available, try reproducing the deadlock above exactly as written, pausing Session 1 right after its first UPDATE to give Session 2 time to run. Watch SQL Server pick a victim and return error 1205 to one of the two sessions — seeing the actual deadlock error is worth more than reading the theory.

    Enjoyed this?

    Subscribe to get every new SQL Server lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes, projects, and 10+ exercises per chapter? Check out SQL Server for Developers & DBAs, coming soon on this site.

  • ACID Transactions in SQL Server: BEGIN, COMMIT, ROLLBACK Explained

    ACID Transactions in SQL Server: BEGIN, COMMIT, ROLLBACK Explained

    You’ve been using BEGIN TRANSACTION, COMMIT, and ROLLBACK since Chapter 4’s stored procedure pattern, largely as boilerplate to copy. This lesson is about the actual guarantee underneath that boilerplate — four properties that guarantee your data stays correct even when things go wrong mid-operation, not just “it undoes things on error.”

    ACID, via a bank transfer(four guarantees, one story)AAtomicityall or nothingCConsistencyvalid state → valid stateIIsolationno peeking, uncommittedDDurabilitysurvives a crashwatch Atomicity work on a transfer:Account 1$5000 → $4000UPDATE #1Account 2$2000 → $3000UPDATE #2$1000 mid-transfer💥 crash here?ROLLBACK — both untouched,$0 lost. that’s Atomicity.

    The Four Properties

    Property Guarantees
    Atomicity All statements succeed together, or none do — no half-finished transaction is ever visible
    Consistency The database moves from one valid state to another — constraints (Fundamentals Ch.6) are never violated, even mid-transaction from another session’s view
    Isolation Concurrent transactions don’t see each other’s uncommitted changes — the specific property Lesson 2 explores in depth
    Durability Once committed, changes survive a crash — guaranteed by the transaction log’s write-ahead logging, from the architecture covered in the Performance Tuning course

    The Classic Transfer

    BEGIN TRY
        BEGIN TRANSACTION;
    
        UPDATE dbo.BankAccount SET balance = balance - 1000 WHERE account_id = 1;
        UPDATE dbo.BankAccount SET balance = balance + 1000 WHERE account_id = 2;
    
        COMMIT TRANSACTION;
    END TRY
    BEGIN CATCH
        IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
        THROW;
    END CATCH;

    What Happens Without a Transaction

    A crash between the two UPDATEs leaves $1000 debited but never credited — money vanishes

    Without wrapping both updates in one transaction, exactly this failure is possible — not hypothetically, but as a genuine risk any time two related writes happen as separate statements. That’s the specific problem transactions exist to prevent, and it’s Atomicity specifically doing the protecting here: SQL Server guarantees that if the crash happens after the first UPDATE but before the second, the entire transaction rolls back on recovery, leaving neither account touched.

    Proving It Yourself

    -- Deliberately introduce a failure between the two updates to watch Atomicity work
    BEGIN TRY
        BEGIN TRANSACTION;
        UPDATE dbo.BankAccount SET balance = balance - 1000 WHERE account_id = 1;
        SELECT 1/0; -- deliberate error, simulates a crash mid-transfer
        UPDATE dbo.BankAccount SET balance = balance + 1000 WHERE account_id = 2;
        COMMIT TRANSACTION;
    END TRY
    BEGIN CATCH
        IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
    END CATCH;
    
    SELECT balance FROM dbo.BankAccount WHERE account_id IN (1,2); -- both unchanged, no money lost
    Practice tip: Run this deliberately-failing version and confirm both balances are unchanged, then remove the SELECT 1/0; line and confirm the transfer completes correctly. Seeing both outcomes, not just reading about them, is what makes ACID feel real rather than theoretical.

    Enjoyed this?

    Subscribe to get every new SQL Server lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes, projects, and 10+ exercises per chapter? Check out SQL Server for Developers & DBAs, coming soon on this site.

  • Row-Level Security in SQL Server: Closing the Gap Application Code Can Miss

    Row-Level Security in SQL Server: Closing the Gap Application Code Can Miss

    Lesson 1’s roles control access at the table level — “can this user query dbo.Order at all.” Row-Level Security (RLS) goes one level finer: which rows within a table a given user can see, enforced by the engine itself rather than trusted entirely to application-layer WHERE clauses. For multi-tenant applications, this closes a genuinely common, genuinely serious real-world bug class.

    RLS: the invisible WHERE clause(a gate every query passes through)WestEastWestNorthSecurityPredicate@region = USER_NAME()runs on EVERY queryWest rows pass ✓others never appear ✗no WHERE clause in the calling query — the filter is invisible and automaticenforced by the engine,not remembered by developersstill keep app-level checks —RLS guards rows, not“can this user act at all” 📌

    A Basic Security Predicate

    CREATE FUNCTION dbo.fn_SecurityPredicate (@region NVARCHAR(50))
    RETURNS TABLE
    WITH SCHEMABINDING
    AS
    RETURN SELECT 1 AS result WHERE @region = USER_NAME() OR IS_MEMBER('db_owner') = 1;
    
    CREATE SECURITY POLICY OrderRegionPolicy
    ADD FILTER PREDICATE dbo.fn_SecurityPredicate(region) ON dbo.SomeTable
    WITH (STATE = ON);

    Once this policy is active, SELECT * FROM dbo.SomeTable run by a non-admin user automatically only returns rows matching their region — with no WHERE clause required in the calling code at all. The filter applies transparently to every query against the table, including ones written by developers who don’t even know RLS exists on it.

    Why This Matters in Practice

    Closes the gap where a forgotten WHERE clause in a new report could leak another tenant’s data

    This is the exact same category of reasoning as constraints vs. application-only validation (Chapter 5): trusting every single query, in every report, written by every developer who ever touches the codebase, to correctly filter by tenant is a much weaker guarantee than the engine enforcing it structurally. One forgotten WHERE tenant_id = @currentTenant in a hastily-written admin report is a real, documented category of data breach — RLS makes that specific mistake impossible rather than merely unlikely.

    Auditing Existing Permissions

    SELECT dp.name AS principal_name, dp.type_desc, o.permission_name, o.state_desc
    FROM sys.database_permissions o
    JOIN sys.database_principals dp ON dp.principal_id = o.grantee_principal_id
    WHERE o.major_id = OBJECT_ID('dbo.OrderLog');

    A quick, worthwhile habit before granting anything new: check what’s already granted on a sensitive table, so you’re not stacking overlapping permissions you can’t easily reason about later. Combined with Lesson 1’s DENY-always-wins rule, a table can accumulate a genuinely confusing tangle of GRANTs and DENYs across multiple roles over time — this query is how you actually see the full picture before adding to it.

    Common mistake: Treating RLS as a replacement for application-level authorization checks entirely. RLS is a defense-in-depth layer (the same philosophy from Chapter 5’s CHECK constraints) — keep sensible application-level filtering too, since RLS protects the data layer specifically, not business logic like “can this user perform this action at all,” which is a broader question than row visibility.
    Practice tip: Build the security policy above against a small table with a handful of rows across two different “regions,” then query it as a non-admin user and confirm rows from the other region genuinely don’t appear — not filtered out by your query, but invisible at the engine level, confirmed by trying to explicitly SELECT a row you know exists in the other region and getting zero rows back.

    Enjoyed this?

    Subscribe to get every new SQL Server lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes, projects, and 10+ exercises per chapter? Check out SQL Server for Developers & DBAs, coming soon on this site.

  • TDE vs Always Encrypted in SQL Server: What Each One Actually Protects

    TDE vs Always Encrypted in SQL Server: What Each One Actually Protects

    Access control (previous lesson) governs who’s allowed to query what. Encryption is a completely different layer: what happens if someone gets to the raw data anyway — a stolen backup, a compromised disk, or even a DBA whose access exceeds what they should see. These get confused in interviews constantly, because both are “encryption,” but they solve genuinely different threats.

    TDE vs Always Encrypted(two threats, two different guarantees)TDEprotects data AT REST —a stolen disk/backup stays lockedbut a live, authorized querystill sees plaintext 👁️Always Encrypted🔒server NEVER sees plaintext —decryption happens client-sideeven a sysadmin queryingdirectly sees ciphertext ✓same query, two tables:SELECT ssn FROM Employee — TDE only→ 555-12-3456 (plaintext)SELECT ssn FROM Employee — Always Enc.→ 0x9F3A… (ciphertext)layered, not either/or —sensitive columns can run both at onceTDE is free once configured —no reason not to run it as abaseline under everything 📌

    The Key Distinction

    TDE Protects data at rest (stolen files/backups) Authorized queries see plaintext Always Encrypted Server never sees plaintext Decryption happens client-side Protects even from DBAs

    The Interview-Ready Answer

    TDE protects against physical theft of files — a stolen backup tape, a compromised disk, a leaked VM snapshot — but a legitimate, authenticated query still sees plaintext once it’s running against the live, unlocked database. A DBA with query access sees everything, TDE or not. Always Encrypted protects against exactly that scenario: even someone with full server access querying the table directly sees only ciphertext for protected columns, because decryption happens in the client driver using a key the server itself never holds.

    -- Even a sysadmin querying a table directly on the server sees ciphertext
    -- for an Always Encrypted column — there's no server-side bypass:
    SELECT ssn FROM dbo.Employee; -- returns encrypted binary garbage, not the real SSN,
    -- UNLESS the querying client has the column master key configured
    
    -- Contrast with TDE: the exact same query, on a TDE-protected database,
    -- returns the real value — TDE is invisible to queries, only protects the files at rest

    Which One for Which Data?

    Sensitive columns like SSNs typically call for Always Encrypted — the goal there is usually protecting against insider visibility, not just physical theft. TDE is a reasonable baseline for the whole database regardless, since it’s essentially free once configured (transparent, as the name says — no query or application changes needed) and closes the physical-theft gap that Always Encrypted alone doesn’t address for unprotected columns. They’re complementary, not substitutes: a well-secured sensitive database often runs both simultaneously.

    Common mistake: Assuming TDE alone is “encryption” in the complete sense and stopping there. TDE is genuinely valuable but answers a narrower question (“what if someone steals the physical files”) than most people assume when they hear “the database is encrypted.” If the threat model includes insider access or a compromised application account, TDE alone doesn’t address it.
    Practice tip: Write out, in one sentence each, the specific attacker scenario TDE stops and the specific scenario Always Encrypted stops. If you can’t state both crisply without looking back at this lesson, that’s the gap most interview answers on this topic actually have — people memorize “TDE = at rest, AE = in use” without being able to explain why that maps to a real threat.

    Enjoyed this?

    Subscribe to get every new SQL Server lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes, projects, and 10+ exercises per chapter? Check out SQL Server for Developers & DBAs, coming soon on this site.

  • Temp Table vs Table Variable vs Global Temp Table: A Decision Framework for SQL Server

    Temp Table vs Table Variable vs Global Temp Table: A Decision Framework for SQL Server

    Three temp object types, three genuinely different jobs. Now that you’ve built all three, here’s how to choose without guessing — and why this exact question shows up so often in interviews.

    Which temp object do you need?Need scratchdata?(start here)Table Variable (@)→ survives ROLLBACKsmall lookups, error logsLocal Temp Table (#)→ real stats + indexesDEFAULT PICKGlobal Temp Table (##)→ another session needs itlast resortno built-in isolationyou add locking yourselfsee previous lessonFolklore says tablevars are faster. Oftenbackwards — check realstats, not guesses. 📌

    The Decision Tree

    Survive a transaction rollback? Yes → Table Variable No → another session needs it? Yes → Global Temp Table No → Local Temp Table

    Situational Cheat Sheet

    Situation Right tool
    Staging a large intermediate result in a complex report Local temp table (#)
    Small lookup list of a few rows inside a procedure Table variable (@)
    Error/audit log that must survive a transaction rollback Table variable (@)
    Sharing a snapshot between two active debugging sessions Global temp table (##)
    Data needs real statistics for the optimizer to make good join choices Local temp table (#)
    You need to add an index only after seeing the shape of the data Local temp table (#) — table variables can’t be altered post-declaration

    This Is a Genuinely Common Interview Question

    “What’s the difference between a temp table and a table variable?” comes up constantly, precisely because the shallow answer (“table variables are smaller/faster”) is folklore, not fact — in practice, on non-trivial row counts, a table variable’s lack of real statistics can make it slower, not faster, exactly because the optimizer’s row estimate is wrong. The strongest interview answer isn’t a definition — it’s the rollback behavior from the previous lesson, because it’s the one difference that actually changes what your code does, not just how fast it runs.

    -- A one-line answer worth having ready: "table variables don't roll back with the
    -- surrounding transaction, and historically carry no real statistics for the optimizer"
    SELECT 'Table variable' AS type, 'Survives rollback, weak statistics' AS behavior
    UNION ALL
    SELECT 'Temp table', 'Rolls back with transaction, real statistics';
    Practice tip: Before moving to stored procedures in the next chapter, pick one real multi-step problem — even something simple like “top 3 highest earners per department” — and consciously decide, using this decision tree, which temp object type (if any) you’d use to solve it. Comparing your reasoning against a CTE-only solution is a useful check too: sometimes the right answer is none of the three.

    Enjoyed this?

    Subscribe to get every new SQL Server lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes, projects, and 10+ exercises per chapter? Check out SQL Server for Developers & DBAs, coming soon on this site.

  • SQL Server Page Structure and Transaction Log Architecture Explained

    SQL Server Page Structure and Transaction Log Architecture Explained

    Lesson 1 covered how SQL Server manages memory and CPU. This lesson covers the physical layer underneath both: how data is actually laid out on disk, and how every single change — no matter how it got there — is guaranteed durable before it’s ever considered committed.

    Pages, Extents & the Log, sketched out1 Extent = 8 contiguous pages (64KB)P0P1P2P3P4P5P6P7P3 = 8KB, only ~8060 bytes usableafter the page header + row offsets1. Write to the LOG firstthe change is recorded before anything else moves2. THEN modify the data pagethe actual 8KB page changes on disk3. COMMIT — only once durablethis is literally Durability (the “D” in ACID)A DELETE touching 1,000,000 rows =1,000,000 log records, row-by-row —no matter how fast the disk is. 📌

    Pages and Extents

    One Extent = 8 contiguous pages (64KB) Page 0 Page 1 Page 2 Each page: 8KB, ~8060 bytes usable after header/row offsets A row larger than one page → row-overflow or LOB storage

    This 8KB figure isn’t arbitrary trivia — it’s the exact unit Chapter 4’s STATISTICS IO “logical reads” count is measured in. When that number reports 500 logical reads, it means 500 8KB pages were touched — tying this architecture lesson directly to a diagnostic number you’ll read constantly for the rest of this course.

    The Transaction Log: Write-Ahead Logging

    -- Check log space usage — a classic "why is my log huge" starting point
    DBCC SQLPERF(LOGSPACE);
    
    -- Check log file growth/autogrowth settings
    SELECT name, size/128 AS size_mb, growth, is_percent_growth
    FROM sys.database_files
    WHERE type_desc = 'LOG';

    SQL Server uses Write-Ahead Logging (WAL): a change is written to the transaction log before the data page itself is modified on disk, and a transaction is only considered committed once its log record is durably written. This is literally how Durability (the “D” in ACID from the Developers & DBAs course) is implemented — not a separate feature, but the mechanism underneath it.

    Why Log Architecture Explains Real Symptoms

    A transaction log that won’t shrink, a database stuck in “log full” errors during a bulk load, a mysteriously slow bulk delete — these all trace back to how logging works: every logged operation, including large deletes, must be written to the log before it’s considered durable, row by row. A DELETE affecting a million rows generates roughly a million log records, regardless of how fast the actual data-page changes would otherwise be.

    Common mistake: Assuming a slow DELETE is an indexing problem. If the WHERE clause is already using a good index but the operation still crawls, the transaction log — not the index — is very often the actual bottleneck, because of exactly this row-by-row logging requirement. Understanding this now sets up exactly the diagnostic instinct you’ll need for Module 3’s “13-Hour Delete” case study, where this precise trap is the twist.
    Practice tip: Run DBCC SQLPERF(LOGSPACE) now, note your practice database’s current log size and percent used, then re-run it after inserting a few thousand synthetic rows in one transaction. Watching the log grow in response to a bulk operation, rather than just reading that it does, is what makes Module 3’s case study land correctly when you get there.

    Enjoyed this?

    Subscribe to get every new lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes and hands-on labs? Check out SQL Server Performance Tuning, coming soon on this site.