Author: admin

  • SQL Server Trigger Best Practices: Why Triggers Are Invisible, and When to Avoid Them

    SQL Server Trigger Best Practices: Why Triggers Are Invisible, and When to Avoid Them

    Triggers are powerful precisely because they’re automatic — which is also exactly why they need to be used deliberately, not as a default habit. This closing lesson of the chapter is about judgment: having built both DML and DDL/logon triggers, here’s when that power is worth the tradeoff and when it isn’t.

    The Trigger You Can’t See(same UPDATE, a hidden consequence below)▲ visible▼ invisibleUPDATE AccountSET balance = 5500!TRIGGER firesno line of app code showsthis is happening at allAccountAudit row writtenlegitimate use — guaranteed…which can fire ANOTHER triggerindirect cycle — not blocked byRECURSIVE_TRIGGERS OFFsys.triggersthe only way to see itwithout reading app codeGotcha: RECURSIVE_TRIGGERS OFF blocks direct self-fire only —an indirect two-table loop can still run forever. 📌

    Four Real Pitfalls

    Pitfall Fix
    Assuming single-row operation Always write set-based logic joining to inserted/deleted (the exact bug proven in Lesson 1)
    Recursive triggers firing themselves Know your nested/recursive triggers DB settings; guard with logic to detect and skip re-entry
    Hidden performance cost Document clearly; keep triggers fast; avoid heavy logic on hot-path, high-write tables
    Multiple triggers, no guaranteed order Prefer one trigger per table/event; use sp_settriggerorder if unavoidable

    Recursive Triggers, Concretely

    -- A trigger on Account that updates Account itself can re-fire the same trigger,
    -- if RECURSIVE_TRIGGERS is on for the database (off by default):
    ALTER DATABASE CURRENT SET RECURSIVE_TRIGGERS OFF; -- the safe default
    
    -- Even with recursion off, an INDIRECT loop is still possible:
    -- trigger on Account updates Order → trigger on Order updates Account → fires the first trigger again
    -- RECURSIVE_TRIGGERS OFF only blocks DIRECT self-triggering, not this indirect cycle
    Common mistake: Assuming RECURSIVE_TRIGGERS OFF (the default) makes trigger loops impossible. It only prevents a trigger from directly re-firing itself — an indirect cycle through a second table’s trigger is still entirely possible and won’t be caught by this setting.

    The Biggest Philosophical Pitfall

    UPDATE Account SET balance = 5500 Looks like a simple update… no visible sign a trigger will fire EXEC usp_UpdateBalance … An explicit, visible decision to run specific logic

    A developer reading application code that runs a plain UPDATE has no way to know a trigger will also fire, unless they separately go check the database schema. A stored procedure call, by contrast, is a visible, greppable, explicit decision to invoke specific logic — anyone reading the call site immediately knows exactly what runs. This invisibility is triggers’ single biggest real-world cost, independent of performance.

    -- A genuinely good way to discover what triggers exist on a table you've inherited:
    SELECT name, is_disabled, OBJECT_DEFINITION(object_id) AS definition
    FROM sys.triggers WHERE parent_id = OBJECT_ID('dbo.Account');

    When Triggers Are Still the Right Call

    Use triggers when you genuinely need guaranteed enforcement regardless of write path — auditing (Lesson 2’s DDL example), cross-table integrity that CHECK constraints can’t express (Chapter 5), or a rule that must apply even to ad-hoc scripts run directly by a DBA, bypassing any application or stored procedure entirely. Avoid them for things a stored procedure or application layer could handle just as reliably, and far more visibly, since “guaranteed no matter what” is the specific property that justifies accepting the invisibility tradeoff — don’t pay that cost for a rule nothing will ever actually bypass.

    Practice tip: Run the sys.triggers query above against any table you’ve built triggers on across this chapter, and read back the definitions via OBJECT_DEFINITION. Getting comfortable discovering triggers this way is a genuinely useful skill for working with a database you didn’t design yourself.

    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.

  • Build a Production-Style SQL Server Backend: A Capstone Ticket System Project

    Build a Production-Style SQL Server Backend: A Capstone Ticket System Project

    This combines every chapter of the advanced track into one realistic deliverable: the backend for TicketDesk, a small support-ticket system, built the way a real backend actually gets built — ambiguous edges, several defensible designs, and a requirement to justify your choices, not just produce code that runs.

    TicketDesk: everything, one schema(each requirement maps to a chapter)Customercreates a ticketusp_Ticket_Create (Ch.1, Ch.4)Ticketstatus, priorityusp_Ticket_Assign (Ch.4)Agentmust be activeTicketAuditAFTER UPDATE trigger,set-based (Ch.7)TicketCommentauthor_type, bodyvw_AgentWorkloadmind the JOIN type — 0-ticket agents must still show (Ch.6)one schema, everychapter of the course ✓the app’s service account:least-privilege, neverdb_owner (Ch.10) 📌

    Schema Requirements

    • Agent: agent_id, name, email (unique), is_active
    • Customer: customer_id, name, email (unique)
    • Ticket: ticket_id, customer_id (FK), assigned_agent_id (FK, nullable — unassigned tickets are a valid state), status, priority, created_at, resolved_at
    • TicketComment: comment_id, ticket_id (FK), author_type, body, created_at
    • TicketAudit: populated automatically by a trigger — old_status, new_status, changed_at

    The Architecture, Visualized

    Customer Ticket Agent TicketAudit TicketComment

    Business Logic Requirements — Mapped to Where You Learned Each One

    Requirement Chapter it draws on
    fn_GetOpenTicketCount(@agentId) — scalar or inline TVF, with a justification comment for which type you chose and why Ch.2
    usp_Ticket_Create — TRY/CATCH + transaction, OUTPUT parameter for the new ticket_id Ch.1, Ch.4
    usp_Ticket_Assign — THROWs if the agent is not active Ch.4
    usp_Ticket_Resolve — THROWs if the ticket is already closed Ch.4, Ch.5
    AFTER UPDATE trigger on Ticket — logs every status change to TicketAudit, correctly set-based for multi-row updates Ch.7
    vw_AgentWorkload — one row per active agent, including agents with zero open tickets (mind the JOIN type) Ch.6
    -- A skeleton for one requirement, deliberately incomplete — you decide the JOIN type
    CREATE VIEW dbo.vw_AgentWorkload AS
    SELECT a.agent_id, a.name, COUNT(t.ticket_id) AS open_ticket_count
    FROM dbo.Agent a
    -- ??? JOIN dbo.Ticket t ON t.assigned_agent_id = a.agent_id AND t.status IN ('open','in_progress')
    WHERE a.is_active = 1
    GROUP BY a.agent_id, a.name;

    The blank above is deliberate: pick the wrong JOIN type here and agents with zero open tickets silently vanish from the report — the exact LEFT JOIN + WHERE-vs-ON distinction from the Fundamentals course, now applied inside a view that a real dashboard would depend on.

    Performance & Security Requirements

    • Populate Ticket with 5,000+ rows and design a covering/filtered index for “open tickets by agent, ordered by priority” — prove it with before/after STATISTICS IO (Ch.8)
    • Create a least-privilege service account for the application — not db_owner, with explicit GRANTs you can justify one by one (Ch.10)

    Self-Check Before You Consider It Done

    Check Why it matters
    Run a multi-row UPDATE against Ticket’s status column and confirm every changed row appears in TicketAudit Catches the single-row-assumption trigger bug from Ch.7
    Call usp_Ticket_Assign against an inactive agent Confirms your THROW logic actually fires, not just compiles
    Query vw_AgentWorkload and confirm an agent with zero tickets still appears, with count 0 Confirms the correct JOIN type from the skeleton above
    Log in as your least-privilege service account and confirm it genuinely cannot do more than granted The only real proof least-privilege was actually applied, not just declared

    Why This Is the Right Capstone

    Every chapter of this track shows up here: functions, procedures with proper error handling, a correctly set-based trigger, a view with the right JOIN type, indexing backed by real measurement, and least-privilege security. It mirrors how a real backend ticket actually gets built — ambiguous edges, multiple valid designs, and a requirement to justify your decisions, not just produce working code.

    What comes next: Combined with the Fundamentals capstone, you now have two complete, defensible schemas behind you — a good portfolio starting point. The Performance Tuning course picks up exactly where this leaves off: given a schema like TicketDesk under real load, how do you diagnose and fix what’s actually slow, using evidence rather than guesswork.

    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

    You’ve completed the full curriculum! Check out SQL Server Fundamentals and SQL Server for Developers & DBAs, coming soon as structured courses on this site.

  • Clustered vs Nonclustered Indexes in SQL Server: The B-Tree Explained

    Clustered vs Nonclustered Indexes in SQL Server: The B-Tree Explained

    Every index recommendation you’ve absorbed passively up to this point (“add an index here”) gets its real mechanical foundation in this chapter. Every SQL Server index is a B-tree: a root page, branch pages, and leaf pages. The difference between clustered and nonclustered is what lives at the leaf level — and that single difference explains almost everything else in this chapter.

    Clustered = The Table Itself(nonclustered is a separate, narrow lookup)CLUSTERED INDEXleaf level = the data itselfid 101 — data rowid 102 — data rowid 103 — data rowid 104 — data rowid 105 — data rowphysically stored in this exact orderat most ONE per tableNONCLUSTERED INDEXleaf level = key + pointer onlystatus=’shipped’ → id 101status=’cancelled’ → id 104status=’shipped’ → id 103MANY nonclustered allowed per tablekey lookup →Gotcha: many key lookups can cost more than one full scan —that’s exactly when the optimizer abandons the index. 📌

    The B-Tree, Visualized

    Root: 1-50000 Branch: 1-16666 Branch: 16667-33333 Branch: 33334-50000 Leaf pages — actual data rows, in key order

    A seek walks root → branch → leaf, typically just 3-4 page reads even against a table with millions of rows — this is the entire reason indexes matter: it turns “read every row” into “read a handful of pages,” a logarithmic rather than linear cost as the table grows.

    Clustered vs Nonclustered

    Clustered Nonclustered
    Leaf level contains The actual data rows Key + pointer back to clustered key
    Per table At most one Many allowed
    Created by default via PRIMARY KEY (Fundamentals Ch.5) Nothing — explicit CREATE INDEX

    This is worth internalizing precisely: a clustered index doesn’t sit “alongside” the table — for a clustered table, the table is the index. There’s no separate copy of the data; the rows are physically stored in clustered-key order. A nonclustered index, by contrast, is a genuinely separate structure, small and narrow, that only stores its key columns plus a pointer back.

    The Key Lookup Problem

    A query that filters on a nonclustered index’s column but selects other columns not in that index requires a key lookup — jumping from the nonclustered leaf back to the clustered index to fetch the rest. For a handful of rows this is cheap; for a large result set, SQL Server often abandons the index entirely and scans the whole table, because thousands of individual lookups cost more than one sequential scan.

    -- Confirm this tipping-point behavior yourself
    CREATE NONCLUSTERED INDEX IX_OrderLog_Status ON dbo.OrderLog (order_status);
    
    -- Selective (few matching rows): optimizer uses the index + key lookups
    SELECT * FROM dbo.OrderLog WHERE order_status = 'cancelled'; -- rare status, few rows
    
    -- Unselective (most rows match): optimizer likely abandons the index for a scan
    SELECT * FROM dbo.OrderLog WHERE order_status = 'completed'; -- common status, most rows
    -- Compare the two actual execution plans (Ctrl+M) to see the optimizer's choice change
    Practice tip: Run both queries above with the actual execution plan visible, and hover over each Seek/Scan operator to read its estimated row count and cost percentage. Seeing the optimizer switch strategies based purely on how selective the filter is — not on anything about the index itself — is the single most useful intuition this lesson can give you before Chapter 3’s execution plan reading lesson goes deeper.

    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.

  • Window Functions in SQL Server: ROW_NUMBER, RANK, DENSE_RANK, and Running Totals

    Window Functions in SQL Server: ROW_NUMBER, RANK, DENSE_RANK, and Running Totals

    The single most important thing to understand about window functions: unlike GROUP BY (Fundamentals Chapter 4), every original row stays in the result, enriched with a calculated value alongside it, rather than collapsed into one row per group. This is the tool for “rank each row within its group” or “running total as of this row” — questions GROUP BY structurally cannot answer, since it always reduces row count.

    Window Functions: Every Row Stays(unlike GROUP BY, which collapses rows)FRAME (current frame)$100$250$400$150$300row 1row 2row 3 (current)row 4 (not yet)row 5 (not yet)running total — SUM() OVER (… UNBOUNDED PRECEDING)1003507509001200

    Ranking Within Groups

    SELECT salesperson, region, amount, sale_date,
        ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC) AS row_num,
        RANK()       OVER (PARTITION BY region ORDER BY amount DESC) AS rank_num,
        DENSE_RANK() OVER (PARTITION BY region ORDER BY amount DESC) AS dense_rank_num
    FROM dbo.Sale;

    PARTITION BY is doing the conceptual work GROUP BY would do — splitting rows into groups — but instead of collapsing each group into one row, it just resets the ranking/calculation at each group boundary while keeping every row visible.

    Running Totals

    SELECT salesperson, region, sale_date, amount,
        SUM(amount) OVER (PARTITION BY region ORDER BY sale_date
                           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total
    FROM dbo.Sale;

    The ROWS BETWEEN ... AND CURRENT ROW clause is called a frame — it defines exactly which rows, relative to the current one, get included in the calculation. “Unbounded preceding to current row” means “every row from the start of this partition up through this one,” which is precisely what a running total means.

    -- A different frame answers a different question: a 3-row moving average
    SELECT salesperson, region, sale_date, amount,
        AVG(amount) OVER (PARTITION BY region ORDER BY sale_date
                           ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS moving_avg_3
    FROM dbo.Sale;

    How Ties Are Handled — The Difference That Actually Matters

    Function Behavior on ties
    ROW_NUMBER() Always unique, arbitrarily breaks ties (1,2,3,4…)
    RANK() Ties share a rank, next rank skips (1,1,3,4…)
    DENSE_RANK() Ties share a rank, next rank doesn’t skip (1,1,2,3…)
    Common mistake: Using ROW_NUMBER() to find “the top 3 salespeople,” which silently discards a genuine 3-way tie for 3rd place down to one arbitrary row. If ties should all be included, RANK() <= 3 is the correct tool — it can return more than 3 rows when there’s a tie at the boundary, which is usually exactly what “top 3” should mean in a real report.

    Comparing to the Previous Row: LAG and LEAD

    SELECT salesperson, sale_date, amount,
        LAG(amount) OVER (PARTITION BY salesperson ORDER BY sale_date) AS previous_sale,
        amount - LAG(amount) OVER (PARTITION BY salesperson ORDER BY sale_date) AS change_from_last
    FROM dbo.Sale;

    LAG reaches backward, LEAD reaches forward — both eliminate what used to require an awkward self-join to compare a row against its neighbor, exactly the SELF JOIN pattern from Fundamentals Chapter 5, now solved far more cleanly.

    GROUP BY vs Window Functions

    GROUP BY Fewer rows — one per group Window Function Every row kept, enriched

    Practice tip: Write a query answering “for each sale, show what percentage it represents of its region’s total” — this needs both a window SUM (the region total, spread across every row) and simple division against each row’s own amount. It’s a genuinely common real-world request that GROUP BY alone cannot answer in a single query.

    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.

  • Common Table Expressions (CTEs) and Recursive CTEs in SQL Server Explained

    Common Table Expressions (CTEs) and Recursive CTEs in SQL Server Explained

    A CTE is a named subquery that makes complex queries genuinely readable — and recursive CTEs solve a problem plain SQL can’t touch at all: hierarchies of unknown, variable depth.

    Recursive CTE: Climbing the Tree★ ANCHOR: level 0Priya (CEO)manager_id = NULLMiguellevel 1Aishalevel 1Liamlevel 2JOIN back to CTE,level + 1 each passGotcha: a bad circular manager_id would recurse forever —MAXRECURSION 100 (the default) stops it automatically. 📌

    A Basic CTE

    WITH RegionSummary AS (
        SELECT region, SUM(amount) AS total_sales
        FROM dbo.Sale
        GROUP BY region
    )
    SELECT region, total_sales FROM RegionSummary WHERE total_sales > 10000;

    Notice this achieves what Chapter 4’s Fundamentals lesson said WHERE can’t do directly — filter on an aggregate — without needing HAVING, because the aggregate is computed inside the CTE first, then the outer query treats total_sales as an ordinary column. A CTE is purely an organizational tool for readability; this exact query could be written as a subquery in FROM instead, with identical results.

    Recursive CTE: Walking a Hierarchy

    CREATE TABLE dbo.OrgChart (
        employee_id INT PRIMARY KEY,
        name        NVARCHAR(50) NOT NULL,
        manager_id  INT NULL REFERENCES dbo.OrgChart(employee_id)
    );
    INSERT INTO dbo.OrgChart VALUES (1,'Priya (CEO)',NULL), (2,'Miguel',1), (3,'Aisha',1), (4,'Liam',2);
    
    WITH OrgHierarchy AS (
        -- Anchor: top of the hierarchy
        SELECT employee_id, name, manager_id, 0 AS level
        FROM dbo.OrgChart WHERE manager_id IS NULL
    
        UNION ALL
    
        -- Recursive: joins back to the CTE itself, one level deeper each pass
        SELECT o.employee_id, o.name, o.manager_id, oh.level + 1
        FROM dbo.OrgChart o
        INNER JOIN OrgHierarchy oh ON o.manager_id = oh.employee_id
    )
    SELECT REPLICATE('  ', level) + name AS org_tree, level FROM OrgHierarchy ORDER BY level, name;

    This is a problem that genuinely cannot be solved with a fixed number of JOINs, because you don’t know upfront how many management levels deep an org chart goes — it could be 3 levels or 15, and a plain query has to be written for a specific, known number of hops. Recursion is the only tool in standard SQL that handles “unknown depth.”

    How Recursion Actually Runs

    Anchor Starting rows (level 0) Recursive member Joins to itself, +1 level each pass

    SQL Server stops automatically once a pass produces zero new rows. A CTE doesn’t persist anywhere and can’t be indexed — it only exists for the single statement it’s attached to, unlike the temp tables from Chapter 3.

    The Infinite Loop Trap

    -- A circular reference (Aisha reports to Liam, who reports to Aisha) would recurse forever
    -- without a safety net. SQL Server defaults to a hard cap of 100 recursion levels:
    SELECT * FROM dbo.OrgChart OPTION (MAXRECURSION 100); -- the implicit default
    
    -- Msg 530 fires automatically once that cap is hit on genuinely bad/circular data:
    -- "The statement terminated. The maximum recursion 100 has been exhausted..."
    
    -- You can raise or lower the cap explicitly (0 = unlimited, use with real caution)
    ;WITH OrgHierarchy AS (...)
    SELECT * FROM OrgHierarchy OPTION (MAXRECURSION 50);
    Common mistake: Assuming your hierarchy data is always clean and skipping any thought about MAXRECURSION. Real org charts, category trees, and bill-of-materials data occasionally do contain a bad circular reference from a data-entry mistake — the default 100-level cap is what turns that into a clear error instead of a runaway query.
    Practice tip: Add a fifth employee to OrgChart reporting to Liam (level 3), rerun the query, and confirm the indentation deepens correctly. Then deliberately create a circular reference (set Priya’s manager_id to Liam’s employee_id) and watch the MAXRECURSION error fire — seeing the safety net trigger for real is worth more than reading about it.

    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 Views Explained: Standard, Indexed, and Updatable Views

    SQL Server Views Explained: Standard, Indexed, and Updatable Views

    Chapter 1 (Fundamentals) previewed views as a saved, reusable SELECT. SQL Server actually offers three genuinely different flavors, each with different rules — and one of them (indexed views) is a real physical storage decision, not just a query-organization convenience.

    A View Is a Window, Not a Copydbo.Sale (the real table)VIEWalways shows live data underneathStandard Viewalways live, zero storageUpdatablesingle table only, writes pass throughIndexed (Materialized)physically stored — not live

    Standard View

    CREATE VIEW dbo.vw_WestRegionSales AS
    SELECT sale_id, salesperson, amount, sale_date FROM dbo.Sale WHERE region = 'West';
    GO
    
    SELECT * FROM dbo.vw_WestRegionSales WHERE amount > 3000;

    Always reflects live data — it’s just a saved query, re-executed each time it’s referenced. A standard view has zero storage cost of its own; think of it as a named, reusable shortcut for a SELECT, nothing more.

    Updatable Views

    UPDATE dbo.vw_WestRegionSales SET amount = 4300 WHERE sale_id = 1;

    A simple, single-table view can accept INSERT/UPDATE directly — SQL Server translates the update against the view back into an update against the underlying Sale table automatically. But it becomes non-updatable the moment it involves a JOIN, GROUP BY/aggregate, DISTINCT, or UNION. SQL Server can no longer unambiguously map an update back to a single row in a single base table.

    -- Confirm this yourself: a JOIN-based view rejects UPDATE outright
    CREATE VIEW dbo.vw_SaleWithRegionName AS
    SELECT s.sale_id, s.amount, r.region_name FROM dbo.Sale s JOIN dbo.Region r ON r.region_id = s.region_id;
    GO
    UPDATE dbo.vw_SaleWithRegionName SET amount = 5000 WHERE sale_id = 1;
    -- Msg 4405: View or function ... is not updatable because the modification affects
    -- multiple base tables.

    Indexed (Materialized) Views

    CREATE VIEW dbo.vw_RegionTotals
    WITH SCHEMABINDING
    AS
    SELECT region, SUM(amount) AS total_sales, COUNT_BIG(*) AS sale_count
    FROM dbo.Sale
    GROUP BY region;
    GO
    
    CREATE UNIQUE CLUSTERED INDEX IX_RegionTotals ON dbo.vw_RegionTotals (region);

    WITH SCHEMABINDING locks the view’s dependency on the base table’s schema — required before you can index a view

    Once indexed, SQL Server physically stores and maintains the aggregate automatically as data changes — genuinely useful for expensive aggregates queried extremely often (a dashboard hit by hundreds of requests per minute), at the cost of slightly slower writes to the base table, since every INSERT/UPDATE/DELETE to Sale now also has to update the materialized total. This is a real space-vs-time tradeoff, the same category of decision you’ll formalize fully once indexing is covered in Chapter 8.

    Common mistake: Adding WITH SCHEMABINDING to a view and then being surprised you can no longer ALTER TABLE ... DROP COLUMN on a column the view references, or drop the base table at all, without first dropping the view. Schema binding is a real, enforced dependency — not just documentation.
    Practice tip: Build vw_RegionTotals exactly as shown, insert a new Sale row, and re-query the view immediately — confirm the total updates without you doing anything extra. That automatic maintenance, seen firsthand, is the entire value proposition of an indexed view.

    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.

  • ON DELETE CASCADE in SQL Server: Powerful, and Genuinely Dangerous If Misused

    ON DELETE CASCADE in SQL Server: Powerful, and Genuinely Dangerous If Misused

    Foreign keys (Fundamentals Chapter 5) can do more than just block invalid deletes — they can propagate changes automatically. That power cuts both ways, and this is one of the few topics in this course where the “safe default” genuinely differs from the option that looks most convenient in a demo.

    Cascade: One Push, Many FallsDELETE Category WHERE id=1Categoryid=1 deletedON DELETE CASCADE →ProductFK CASCADEON DELETE CASCADE →ReviewFK CASCADEGotcha: one DELETE can silently wipe out far more thanintended — always COUNT(*) the blast radius first. 📌

    The Four Options

    CREATE TABLE dbo.Category2 (
        category_id INT IDENTITY(1,1) PRIMARY KEY,
        name        NVARCHAR(50) NOT NULL
    );
    
    CREATE TABLE dbo.Product2 (
        product_id  INT IDENTITY(1,1) PRIMARY KEY,
        category_id INT NOT NULL
            REFERENCES dbo.Category2(category_id) ON DELETE CASCADE ON UPDATE CASCADE,
        name        NVARCHAR(100) NOT NULL
    );
    Option Behavior when parent row is deleted
    NO ACTION (default) Blocks the delete if child rows reference it — exactly the Chapter 5 (Fundamentals) behavior you’ve already seen
    CASCADE Automatically deletes matching child rows too
    SET NULL Sets the child’s FK column to NULL (requires the FK column to allow NULL)
    SET DEFAULT Sets the child’s FK column to its DEFAULT value (requires one to be defined)

    The Real Risk

    CASCADE on a deep relationship chain can silently wipe out far more data than intended from one top-level delete

    Seeing this concretely: if Category → Product is CASCADE, and Product → Review is also CASCADE, then one DELETE FROM Category2 WHERE category_id = 1 can silently remove every product in that category and every review those products ever received — all from a single statement, with no confirmation, no listing of what’s about to disappear. Multi-level CASCADE chains are exactly how a well-intentioned “clean up an old category” operation becomes an incident.

    -- Prove the blast radius yourself before trusting a cascade chain in production:
    SELECT COUNT(*) FROM dbo.Product2 WHERE category_id = 1; -- how many products?
    -- If Review also cascades from Product, this number matters too:
    -- SELECT COUNT(*) FROM dbo.Review WHERE product_id IN (SELECT product_id FROM dbo.Product2 WHERE category_id = 1);

    A Concrete Decision Rule

    CASCADE is right for genuinely dependent data — data with no meaning or value once its parent is gone (delete an Order, its OrderItems should go too; nobody wants orphaned line items pointing at a deleted order). It’s the wrong choice for anything with independent value — don’t cascade-delete Product just because a Category is removed; the products still exist and are still sellable, they just need a new category. Usually you want NO ACTION there, forcing an explicit, visible decision (reassign the products, or delete them deliberately) instead of an invisible side effect.

    SQL Server also blocks a specific dangerous configuration outright: you cannot create multiple CASCADE paths that could reach the same table through different routes, since it can’t guarantee a deterministic order of operations — a useful, hardcoded safety net worth knowing about rather than fighting.

    Practice tip: Before adding ON DELETE CASCADE to any real foreign key, write the SELECT COUNT(*) query that shows exactly what a worst-case delete would remove, and actually run it against realistic data volume. If that number would alarm you in production, NO ACTION is very likely the correct default, not CASCADE.

    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.

  • Computed Columns and Multi-Column CHECK Constraints in SQL Server

    Computed Columns and Multi-Column CHECK Constraints in SQL Server

    CHECK constraints get genuinely powerful once they can reference multiple columns in the same row — exactly the boundary the previous lesson drew — and computed columns let you derive values automatically instead of trusting every INSERT/UPDATE to calculate them correctly and consistently.

    Computed Columns: Derived, Not Writtencheck_inDATE columncheck_outDATE columnnightly_rateDECIMAL, CHECK > 0nightsAS DATEDIFF(day,in,out)🔒 auto-derived, read-onlytotal_costAS nights * nightly_rate🔒 PERSISTED, indexableCHECK(check_out > check_in)

    A Table Using Both

    CREATE TABLE dbo.Booking (
        booking_id  INT IDENTITY(1,1) PRIMARY KEY,
        check_in    DATE NOT NULL,
        check_out   DATE NOT NULL,
        nightly_rate DECIMAL(8,2) NOT NULL CHECK (nightly_rate > 0),
        nights      AS DATEDIFF(DAY, check_in, check_out),
        total_cost  AS (DATEDIFF(DAY, check_in, check_out) * nightly_rate) PERSISTED,
        CONSTRAINT CK_Booking_Dates CHECK (check_out > check_in)
    );
    
    INSERT INTO dbo.Booking (check_in, check_out, nightly_rate) VALUES ('2024-06-01', '2024-06-05', 150.00);
    SELECT booking_id, nights, total_cost FROM dbo.Booking;

    Notice nights and total_cost are never written to directly — you can’t INSERT a value into them, and SQL Server rejects the attempt if you try. They’re recalculated automatically from check_in, check_out, and nightly_rate every time those source columns change, which structurally guarantees they can never drift out of sync the way a manually-maintained “total” column could.

    PERSISTED: Store It or Compute It Live?

    Not PERSISTED Recalculated on every SELECT Cannot be indexed PERSISTED Stored on disk, auto-synced Can be indexed

    Worthwhile when the column is read often relative to how often source columns change — total_cost on a booking is a good candidate, since it’s likely queried far more often than a booking’s dates ever change after creation. A table-level CHECK constraint (like CK_Booking_Dates) can reference multiple columns in the same row — just never another table or another row, which was exactly the boundary the previous lesson established.

    A Deterministic Requirement, Revisited

    -- FAILS: a computed column referencing GETDATE() cannot be PERSISTED or indexed,
    -- for the exact same non-deterministic reason a scalar function couldn't be (Chapter 2)
    ALTER TABLE dbo.Booking ADD days_until_checkin AS DATEDIFF(DAY, GETDATE(), check_in) PERSISTED;
    -- Msg 4936: Computed column 'days_until_checkin' in table 'Booking' cannot be persisted
    -- because the column is non-deterministic.
    Common mistake: Trying to PERSIST a computed column that depends on the current date/time or a random value, then being confused by the deterministic-function error. This is the same rule from Chapter 2’s scalar functions, applied to computed columns instead — a persisted value must be reproducible from its inputs alone, with nothing external sneaking in.
    Practice tip: Add a second computed column to Booking — is_long_stay AS CASE WHEN DATEDIFF(DAY, check_in, check_out) > 7 THEN 1 ELSE 0 END — and confirm you can filter on it directly in a WHERE clause, exactly like a normal column.

    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.

  • Constraints vs Triggers vs Procedures: Where to Enforce a SQL Server Business Rule

    Constraints vs Triggers vs Procedures: Where to Enforce a SQL Server Business Rule

    You now have four genuinely different tools capable of enforcing the same underlying rule — CHECK constraints (Fundamentals Ch.6), foreign keys, triggers (next up, Chapter 7), and stored procedures (just built). Choosing the right layer is a decision every schema designer eventually faces, and getting it wrong in either direction — too rigid or too loose — causes real, recurring pain.

    Where Should This Rule Live?(push it as close to the data as it can go)DATAcheapest, most guaranteedCHECK / FKsingle-row or existence rule(Fundamentals Ch.5-6)TRIGGERcross-row / cross-table logic(this chapter, next up)PROCEDUREmulti-step business process(sole write path required)most flexible, easiest to bypassfarther from data → more powerful, harder to guarantee

    The Options, Compared

    Mechanism Best for Limitation
    CHECK constraint Simple, single-row rules (price > 0) Cannot reference other tables or other rows
    FOREIGN KEY Referential existence rules Only “must exist,” not conditional logic
    Trigger Cross-row or cross-table logic, automatic side effects Harder to reason about, invisible to callers reading application code alone
    Stored procedure (as sole write path) Complex multi-step processes with clear business logic Only works if literally nothing else can bypass it

    The Guiding Principle

    Push a rule as close to the data as it can go without becoming unmaintainable

    A single-column rule belongs in a CHECK constraint — cheap, guaranteed, self-documenting, and enforced no matter what wrote the data (the exact reasoning from Fundamentals Chapter 6). A rule spanning multiple tables usually needs a trigger, precisely because CHECK constraints can’t reference other tables. A rule that’s genuinely a business process — multiple steps, conditional branches, needing to notify or log along the way — belongs in a stored procedure, provided your architecture guarantees that procedure is the only path data takes into the table.

    Walking a Real Rule Through All Four Layers

    Take: “a booking’s total nights can never exceed 30.” Watch how the same rule looks completely different depending on which layer enforces it:

    -- As a CHECK constraint: works ONLY if check_in/check_out are both plain columns on one row
    ALTER TABLE dbo.Booking ADD CONSTRAINT CK_MaxStay CHECK (DATEDIFF(DAY, check_in, check_out) <= 30);
    
    -- As a trigger: needed instead if the rule required looking at OTHER bookings
    -- (e.g. "no guest can have more than 30 total nights booked across all their reservations")
    CREATE TRIGGER trg_Booking_MaxTotalNights ON dbo.Booking AFTER INSERT AS
    BEGIN
        IF EXISTS (
            SELECT guest_id FROM dbo.Booking
            WHERE guest_id IN (SELECT guest_id FROM inserted)
            GROUP BY guest_id HAVING SUM(DATEDIFF(DAY, check_in, check_out)) > 30
        )
            THROW 51020, 'Guest exceeds 30 total booked nights.', 1;
    END;

    The single-row version is a CHECK constraint’s job exactly. The moment the rule needs to look across multiple rows (all of one guest’s bookings, not just the one being inserted), a CHECK constraint structurally cannot do it — that’s the trigger’s job instead. This is the actual decision criterion, not a vague sense of “complexity.”

    Practice tip: For any business rule you’re about to enforce, ask one concrete question first: “does validating this require looking at data outside the single row being changed?” If no, CHECK constraint. If yes, trigger (or procedure, if it’s really a multi-step process rather than a validation). That one question resolves the large majority of real cases.

    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.

  • Stored Procedures vs Functions in SQL Server: When to Choose Which

    Stored Procedures vs Functions in SQL Server: When to Choose Which

    These get confused constantly, including in interviews. Here’s the clean distinction, grounded in everything you’ve built across Chapters 2 and 4 rather than as a fresh set of rules to memorize.

    Procedure vs Function(the one rule, sketched out)Need to WRITE data, orcontrol a TRANSACTION?(or return multiple result sets?)YESNOPROCEDURE✓ INSERT / UPDATE / DELETE✓ BEGIN/COMMIT/ROLLBACK, TRY/CATCH✓ can return many result sets✗ cannot be called inside SELECTFUNCTION✓ callable inside a SELECT / JOIN✓ composable, read-only building block✗ cannot write data (compile error)✗ no BEGIN/COMMIT or TRY/CATCH

    Side by Side

    Stored Procedure Function (any type)
    Modify data (INSERT/UPDATE/DELETE) Yes No — compile error if attempted
    Manage transactions (BEGIN/COMMIT/ROLLBACK) Yes No
    Callable inside a SELECT statement No Yes
    Return multiple result sets Yes No — exactly one value or one table
    Use TRY/CATCH Yes No
    Precompiled and cached like a procedure Yes Scalar/mTVF: yes; iTVF: inlines instead (Chapter 2)

    The One-Sentence Rule

    Need to write data, manage a transaction, or return multiple result sets? → Procedure. Otherwise → Function.

    Functions’ composability inside SELECT statements is their key advantage — but only for read-only, single-result-shape logic. The moment you need to write data or control a transaction explicitly, you’re in stored procedure territory, no exceptions. This isn’t a style preference; it’s enforced by the engine, as Chapter 2’s “side-effecting operator” error demonstrated directly.

    A Realistic Mixed Scenario

    Real applications typically need both, working together: an iTVF to expose a reusable, JOIN-friendly “active customers this quarter” query, and a stored procedure that uses that same iTVF internally as part of a larger workflow that also writes an audit log row and sends the result back to the caller.

    CREATE PROCEDURE dbo.usp_GenerateQuarterlyReport @quarter INT AS
    BEGIN
        SET NOCOUNT ON;
        -- Reuses an iTVF from earlier in the chapter for the read-only part
        SELECT * FROM dbo.GetEmployeesByDepartment('Sales');
        -- Then does something only a procedure can: write an audit trail
        INSERT INTO dbo.ReportLog (report_name, generated_at) VALUES ('Quarterly Sales', SYSDATETIME());
    END;

    This is the natural end state once you’ve internalized both tools: functions for the composable, read-only building blocks; procedures for orchestrating them alongside anything with a side effect.

    Practice tip: Look back at any function you wrote in Chapter 2 and ask: “if I needed this to also log who called it, could I?” The answer is no — that’s the exact moment a function needs to become, or be wrapped by, a procedure instead.

    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.