Tag: SQL Server

  • Primary Keys and Foreign Keys in SQL Server: How Tables Actually Connect

    Primary Keys and Foreign Keys in SQL Server: How Tables Actually Connect

    Everything up to this chapter queried one table at a time. Real schemas are almost never one table โ€” they’re a web of tables connected by keys, and understanding exactly how that connection is enforced (not just “it points to the other table”) is what makes JOINs in the next lesson click instead of feeling like memorized syntax.

    Primary & Foreign Keys, connected(how two tables actually link up)Driverdriver_id (PK)full_namecityone row per driverTriptrip_id (PK)driver_id (FK)distance_kmmany rows per driverFK points to a PKINSERT Trip driver_id=999driver_id 999 isn’t in DriverSQL Server rejects itGotcha: deleting aDriver with existingTrips fails by default โ€”no auto-cascade!

    Setup โ€” a driver/trip example to follow along:

    CREATE TABLE dbo.Driver (
        driver_id   INT IDENTITY(1,1) PRIMARY KEY,
        full_name   NVARCHAR(100) NOT NULL,
        city        NVARCHAR(50)  NOT NULL
    );
    CREATE TABLE dbo.Trip (
        trip_id       INT IDENTITY(1,1) PRIMARY KEY,
        driver_id     INT NOT NULL REFERENCES dbo.Driver(driver_id),
        distance_km   DECIMAL(6,2) NOT NULL,
        fare_usd      DECIMAL(8,2) NOT NULL
    );

    The Relationship, Visualized

    Driver driver_id (PK) full_name city Trip trip_id (PK) driver_id (FK) distance_km 1-to-many

    A primary key (PK) uniquely identifies each row (driver_id in Driver) โ€” SQL Server automatically creates a unique index behind every primary key, which is why lookups by PK are fast by default, before you’ve even thought about indexing. A foreign key (FK) is a column in one table that points to a primary key in another (Trip.driver_id โ†’ Driver.driver_id).

    Watching the Constraint Actually Enforce Something

    -- This fails โ€” driver_id 999 doesn't exist in Driver:
    INSERT INTO dbo.Trip (driver_id, distance_km, fare_usd) VALUES (999, 5.2, 12.50);
    -- Msg 547: The INSERT statement conflicted with the FOREIGN KEY constraint ...
    
    -- This fails too โ€” you can't delete a driver who still has trips referencing them:
    INSERT INTO dbo.Driver (full_name, city) VALUES ('Amir Khan', 'Austin');
    INSERT INTO dbo.Trip (driver_id, distance_km, fare_usd) VALUES (1, 8.0, 18.00);
    DELETE FROM dbo.Driver WHERE driver_id = 1;
    -- Msg 547: The DELETE statement conflicted with the REFERENCE constraint ...

    This is the entire point of a foreign key: SQL Server enforces that you can’t log a trip for a driver who doesn’t exist, and can’t delete a driver out from under existing trips โ€” it rejects the operation outright, rather than silently leaving a trip pointing at nothing (an “orphaned row”). Without this constraint, that kind of data corruption is entirely possible and often goes unnoticed until a report breaks months later.

    Three Relationship Shapes

    Shape Example How it’s modeled
    One-to-many (1:N) One driver, many trips A foreign key on the “many” side, as shown above
    Many-to-many (N:N) Many students enroll in many courses A junction table in between, holding two foreign keys โ€” e.g. Student โ†” Course via Enrollment
    One-to-one (1:1) Employee โ†” EmployeeConfidentialDetails A foreign key with a UNIQUE constraint added โ€” rare, often used to split sensitive columns into a separately-secured table

    A Many-to-Many Example, Concretely

    CREATE TABLE dbo.Student (student_id INT IDENTITY PRIMARY KEY, name NVARCHAR(100) NOT NULL);
    CREATE TABLE dbo.Course (course_id INT IDENTITY PRIMARY KEY, title NVARCHAR(100) NOT NULL);
    
    -- The junction table: one row per student-course PAIR, with a composite primary key
    CREATE TABLE dbo.Enrollment (
        student_id INT NOT NULL REFERENCES dbo.Student(student_id),
        course_id  INT NOT NULL REFERENCES dbo.Course(course_id),
        enrolled_on DATE NOT NULL DEFAULT GETDATE(),
        PRIMARY KEY (student_id, course_id)
    );

    Neither Student nor Course has a foreign key pointing directly at the other โ€” they can’t, since either side could relate to many rows on the other. The junction table’s composite primary key (both columns together) also does double duty: it prevents the same student from enrolling in the same course twice.

    Practice tip: Sketch the junction table for a “many books can have many authors, many authors can write many books” relationship before the Chapter 7 capstone โ€” you’ll build exactly this schema there for real.

    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 Fundamentals, coming soon on this site.

  • System vs User-Defined Functions in SQL Server: A First Look

    System vs User-Defined Functions in SQL Server: A First Look

    Every function you’ve used so far โ€” COUNT, GETDATE, UPPER โ€” ships with SQL Server itself. Soon (in the advanced course) you’ll write your own. Here’s the map before you get there, including why the choice between function types is a genuine engineering decision, not just a syntax preference.

    Built-in Toolbox vs. Your Own(system functions vs user-defined)System Functionsbuilt-in โ€” read onlyGETDATE()SUM()UPPER()COUNT()Your Functionsyou write theseCalculateAge()GetActiveStartups()GetStartupReport()vsA scalar UDF in WHERE can run onceper row, invisible to the optimizer โ€”“which type” is a real perf decision.

    Two Categories

    Type Example Who defines it
    System function GETDATE(), SUM(), UPPER() Built into SQL Server
    Scalar UDF dbo.CalculateAge(birthdate) You write it, returns one value
    Inline table-valued (iTVF) dbo.GetActiveStartups() You write it, returns a table, behaves like a parameterized view
    Multi-statement TVF dbo.GetStartupReport(@year) You write it, builds a table procedurally, statement by statement

    A Preview of What a Scalar UDF Looks Like

    You’re not expected to write this yet, but seeing the shape demystifies it โ€” it’s really just a named, reusable calculation:

    CREATE FUNCTION dbo.CalculateAge (@birthdate DATE)
    RETURNS INT
    AS
    BEGIN
        RETURN DATEDIFF(YEAR, @birthdate, GETDATE());
    END;
    
    -- Once created, it's used exactly like a built-in function:
    SELECT name, dbo.CalculateAge('1995-06-15') AS age FROM dbo.Startup;

    Notice it slots directly into a SELECT list, just like UPPER() or DATEDIFF() โ€” from the caller’s perspective, a well-written scalar UDF is indistinguishable from a system function. That’s exactly the point: it extends SQL Server’s function library with your own domain-specific logic.

    Why the Choice Between These Types Actually Matters

    This isn’t just a naming exercise. A scalar UDF called inside a WHERE clause against every row of a large table can quietly turn a query that should take milliseconds into one that takes seconds โ€” because (in most SQL Server versions before 2019’s scalar UDF inlining improvements) the function executes once per row, outside the query optimizer’s usual cost-based reasoning. An inline TVF, by contrast, gets expanded directly into the surrounding query and optimized like ordinary SQL. This single distinction โ€” can the optimizer see through it or not โ€” is one of the most consequential real-world performance decisions a T-SQL developer makes, and it’s covered in full in the advanced course once you have the query-plan-reading skills to actually verify the difference yourself.

    Where This Goes Next

    This Course Use system functions confidently and correctly Advanced Track Write your own scalar, iTVF, and mTVF functions

    Writing your own functions โ€” and understanding the real performance tradeoffs between the three types โ€” is a full topic in SQL Server for Developers & DBAs (Chapter 2 there is dedicated entirely to this). For now, the goal is simply recognizing that this whole other category exists, and that “which function type” is a real design decision, not an arbitrary label.

    Practice tip: Next time you use a built-in function like DATEDIFF or ROUND, pause and ask: “if I had to write this myself as a scalar function, what would the body look like?” You already have enough T-SQL from this chapter to answer that for several of them โ€” which is exactly the mental bridge to writing real UDFs later.

    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 Fundamentals, coming soon on this site.

  • SQL Server Built-in Functions: String, Date, and Math Functions You’ll Use Daily

    SQL Server Built-in Functions: String, Date, and Math Functions You’ll Use Daily

    Beyond aggregates, SQL Server ships a huge library of scalar functions โ€” functions that take input and return one value per row, rather than collapsing many rows into one. Here are the ones you’ll actually reach for constantly, with the specific edge cases that catch people off guard.

    Before โ†’ Function โ†’ After(three scalar transforms, same shape)‘DataForge’beforeUPPER()‘DATAFORGE’afterDec 31 โ†’ Jan 11 day apartDATEDIFF(YEAR,..)returns 1boundary crossed, not 365 days!7 / 2both operands INTinteger division3fractional part silently droppedTwo silent gotchas here:DATEDIFF counts boundariescrossed, not elapsed time โ€”and INT / INT truncates,it doesn’t round. Cast toDECIMAL when it matters.

    String Functions

    SELECT UPPER(name), LOWER(name), LEN(name) FROM dbo.Startup;
    SELECT CONCAT(name, ' (', industry, ')') AS label FROM dbo.Startup;
    SELECT SUBSTRING(name, 1, 4) FROM dbo.Startup;
    SELECT TRIM('  padded  ') AS cleaned;
    SELECT REPLACE(name, 'Data', 'Info') FROM dbo.Startup;
    SELECT LEFT(name, 3), RIGHT(name, 3) FROM dbo.Startup;
    Common mistake: Using + to concatenate strings when one side might be NULL โ€” first_name + ' ' + last_name returns NULL for the entire expression if either piece is NULL. CONCAT() treats NULL as an empty string instead, which is almost always what you actually want: CONCAT(first_name, ' ', last_name).

    Date Functions

    SELECT GETDATE() AS right_now;              -- current date + time
    SELECT SYSDATETIME() AS right_now_precise;  -- higher precision, preferred in new code
    SELECT YEAR(GETDATE()) AS current_year;
    SELECT DATEDIFF(YEAR, '2020-01-01', GETDATE()) AS years_since_2020;
    SELECT DATEADD(MONTH, 6, GETDATE()) AS six_months_from_now;
    SELECT DATENAME(WEEKDAY, GETDATE()) AS day_name;  -- e.g. 'Friday'
    Common mistake: DATEDIFF(YEAR, ...) counts calendar-year boundaries crossed, not full 365-day years. DATEDIFF(YEAR, '2020-12-31', '2021-01-01') returns 1, even though only one day actually passed โ€” because a year boundary (Dec 31 โ†’ Jan 1) was crossed. This surprises almost everyone the first time they compute an “age” this way and get a value that’s off by one right around a birthday or anniversary.

    Math and Rounding Functions

    SELECT ROUND(funding_usd / 1000000.0, 2) AS funding_millions FROM dbo.Startup;
    SELECT CEILING(4.1) AS rounds_up;   -- 5
    SELECT FLOOR(4.9) AS rounds_down;   -- 4
    SELECT ABS(-42) AS absolute_value;  -- 42

    The Integer Division Trap

    SELECT 7 / 2 AS wrong_answer;      -- 3, NOT 3.5 โ€” both operands are INT, result truncates
    SELECT 7.0 / 2 AS correct_answer;  -- 3.5 โ€” forcing one operand to a decimal type fixes it
    SELECT CAST(7 AS DECIMAL(10,2)) / 2 AS also_correct;
    Common mistake: Dividing two INT columns and expecting a decimal result. SQL Server (like most languages) performs integer division when both operands are integers โ€” the fractional part is silently discarded, not rounded, with no error or warning. This is a genuinely common source of “my percentage calculation shows 0” bugs. Always cast at least one side to a decimal type when division needs to be exact.

    NULL-Handling and Conversion Functions

    SELECT ISNULL(NULL, 'fallback value') AS demo;               -- returns 'fallback value'
    SELECT COALESCE(NULL, NULL, 'third option') AS demo2;        -- returns 'third option'
    SELECT CAST(funding_usd AS BIGINT) AS funding_rounded FROM dbo.Startup;
    SELECT TRY_CAST('not a number' AS INT) AS safe_conversion;   -- returns NULL, not an error

    TRY_CAST (and its cousin TRY_CONVERT) return NULL instead of throwing an error when a conversion fails โ€” invaluable when converting messy, real-world data where you can’t guarantee every value is well-formed.

    Quick Reference

    Function Does
    LEN() Character length of a string
    CONCAT() Joins strings, treating NULL as empty rather than poisoning the whole result
    DATEDIFF() Difference between two dates in a given unit โ€” counts boundaries crossed, not elapsed duration
    ROUND() Rounds a number to N decimal places
    ISNULL() / COALESCE() Replaces NULL with a fallback value
    TRY_CAST() / TRY_CONVERT() Converts types, returning NULL instead of erroring on bad input
    Practice tip: Write a query that calculates each startup’s funding per employee (funding_usd / employees), formatted to 2 decimal places. Notice you need to think about integer division here too if employees were an INT divided by another INT โ€” it isn’t in this case since funding_usd is DECIMAL, but it’s worth confirming that for yourself by checking the result type.

    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 Fundamentals, coming soon on this site.

  • COUNT, SUM, AVG in SQL Server: Turning Rows Into Insight

    COUNT, SUM, AVG in SQL Server: Turning Rows Into Insight

    Everything so far has returned individual rows. Aggregate functions are the first tool that collapses many rows into one summary value โ€” the foundation of every report, dashboard, and “how many / how much / on average” question you’ll ever answer with SQL.

    Turning Rows Into Insight(many rows โ†’ one number)COUNT(*)3 rows โ€” always a real numberSUM(funding_usd)$45,000,000 totalAVG(employees)65 โ€” mean team sizeCOUNT(*) vs COUNT(column) โ€” not the same question:COUNT(*)counts every row โ€” NULLs includedacquired_by is NULL? still +1COUNT(acquired_by)only counts non-NULL valueshere: just 1 (PayFlow)SUM / AVG / MIN / MAX return NULLon an empty group โ€” not 0. COUNT(*)is the one exception.

    Setup โ€” a small startup funding dataset to follow along:

    CREATE TABLE dbo.Startup (
        startup_id    INT IDENTITY(1,1) PRIMARY KEY,
        name          NVARCHAR(100) NOT NULL,
        industry      NVARCHAR(50)  NOT NULL,
        founded_year  INT           NOT NULL,
        funding_usd   DECIMAL(15,2) NOT NULL,
        employees     INT           NOT NULL
    );
    INSERT INTO dbo.Startup (name, industry, founded_year, funding_usd, employees) VALUES
    ('DataForge', 'AI', 2019, 12000000, 45),
    ('CloudNest', 'Cloud Infra', 2018, 25000000, 120),
    ('PayFlow', 'Fintech', 2020, 8000000, 30);

    The Five Core Aggregate Functions

    SELECT COUNT(*) AS total_startups FROM dbo.Startup;
    SELECT SUM(funding_usd) AS total_funding FROM dbo.Startup;
    SELECT AVG(employees) AS avg_team_size FROM dbo.Startup;
    SELECT MIN(founded_year) AS oldest, MAX(founded_year) AS newest FROM dbo.Startup;

    COUNT(*) vs COUNT(column): Not the Same Thing

    COUNT(*) Counts every row, regardless of NULLs COUNT(column) Only counts rows where that column is NOT NULL

    -- Add a nullable column to see the difference for real
    ALTER TABLE dbo.Startup ADD acquired_by NVARCHAR(100) NULL;
    UPDATE dbo.Startup SET acquired_by = 'BigTech Corp' WHERE name = 'PayFlow';
    
    SELECT COUNT(*) AS total_rows,               -- 3 (every row counts)
           COUNT(acquired_by) AS acquired_count  -- 1 (only non-NULL values count)
    FROM dbo.Startup;

    These can give genuinely different answers on the same table. Always be deliberate about which one you actually mean โ€” “how many rows” and “how many rows have a value here” are different questions, and mixing them up silently produces a wrong number, not an error.

    COUNT(DISTINCT …): A Third Variant

    SELECT COUNT(DISTINCT industry) AS unique_industries FROM dbo.Startup;

    Counts how many distinct non-NULL values appear, not how many rows โ€” useful for “how many different X do we have,” as opposed to “how many rows mention X.”

    Aggregates and NULL: The Result Isn’t Always 0

    -- On an empty result set (e.g. after a WHERE that matches nothing):
    SELECT SUM(funding_usd) FROM dbo.Startup WHERE industry = 'Biotech'; -- returns NULL, not 0
    SELECT COUNT(*) FROM dbo.Startup WHERE industry = 'Biotech';         -- returns 0, correctly
    Common mistake: Assuming SUM/AVG/MIN/MAX return 0 when there’s nothing to aggregate. They return NULL โ€” which then silently propagates into any further arithmetic (NULL + 100 is still NULL). Wrap the result in ISNULL(SUM(funding_usd), 0) if a report genuinely needs to show zero rather than blank for an empty group. COUNT(*) is the one exception โ€” it always returns a real number, since “count of nothing” is meaningfully zero.

    Combining Multiple Aggregates in One Query

    SELECT
        COUNT(*) AS total_startups,
        SUM(funding_usd) AS total_funding,
        AVG(funding_usd) AS avg_funding,
        MAX(funding_usd) AS biggest_round,
        MIN(founded_year) AS oldest_founding_year
    FROM dbo.Startup;

    Every aggregate in a single SELECT (with no GROUP BY) computes over the same full set of matching rows โ€” this is a common, efficient way to build a one-row “summary card” for a dashboard in a single round trip.

    Key Takeaways

    • COUNT, SUM, AVG, MIN, MAX collapse many rows into one summary value
    • COUNT(*) counts rows; COUNT(column) counts non-NULL values in that column specifically; COUNT(DISTINCT column) counts unique non-NULL values
    • Aggregate functions (other than COUNT(*)) return NULL, not 0, when there’s nothing to aggregate โ€” wrap in ISNULL/COALESCE if you need a real zero
    Practice tip: Add two more startups to the table with different industries, then write a single query returning the total count, total funding, and average team size โ€” from memory, without re-reading the examples above.

    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 Fundamentals, coming soon on this site.

  • GROUP BY and HAVING in SQL Server: The Rule That Trips Everyone Up

    GROUP BY and HAVING in SQL Server: The Rule That Trips Everyone Up

    Aggregate functions get far more useful once you can compute them per-group instead of across the whole table โ€” “total funding” is fine, but “total funding per industry” is what a real report actually needs. This is also the lesson where a genuinely confusing rule shows up for the first time, so we’ll spend real time on why it exists, not just what it says.

    Rows โ†’ Bins โ†’ Filter(GROUP BY sorts, HAVING filters what’s left)6 raw rowsGROUP BY industryAI3 startupsCloud Infra2 startupsFintech1 startupHAVING COUNT(*) > 1 โ€” only bins with more than one row survivekeptkeptdropped (only 1)HAVING filters GROUPS, not rows.WHERE can’t see COUNT(*) โ€” it runsbefore grouping even happens.

    One Row Per Group

    SELECT industry, COUNT(*) AS startup_count, AVG(funding_usd) AS avg_funding
    FROM dbo.Startup
    GROUP BY industry
    ORDER BY avg_funding DESC;

    Instead of one row per startup, you get one row per distinct industry value, each with its own COUNT and AVG computed only from the rows in that group.

    Grouping by More Than One Column

    SELECT industry, founded_year, COUNT(*) AS count_that_year
    FROM dbo.Startup
    GROUP BY industry, founded_year;

    Each unique combination of industry and founded_year becomes its own group โ€” grouping by more columns always produces more (or equal), never fewer, groups than grouping by one.

    HAVING: Filtering Groups, Not Rows

    SELECT industry, SUM(funding_usd) AS total_funding
    FROM dbo.Startup
    GROUP BY industry
    HAVING SUM(funding_usd) > 15000000;

    The Pipeline, Visualized

    WHERE (rows) GROUP BY (collapse) HAVING (groups) ORDER BY

    This ordering (an extension of the FROM โ†’ WHERE โ†’ SELECT โ†’ ORDER BY pipeline from Chapter 3) is the key to understanding both HAVING and the SELECT-column rule below: WHERE filters raw rows before grouping happens; HAVING filters the already-formed groups after. They operate on fundamentally different things, which is exactly why you need both, and why they can’t substitute for each other.

    The Rule That Trips Everyone Up

    Every column in SELECT must either be in the GROUP BY list, or wrapped in an aggregate function:

    -- FAILS: name is neither grouped nor aggregated
    SELECT name, industry, AVG(funding_usd) FROM dbo.Startup GROUP BY industry;
    -- Msg 8120: Column 'dbo.Startup.name' is invalid in the select list because it is
    -- not contained in either an aggregate function or the GROUP BY clause.

    Once rows collapse into groups, SQL Server has no single value to show for name โ€” there could be several startups per industry, each with a different name. It has no way to know which one you want, so it refuses to guess. This is arguably the most useful error message to truly understand in this entire chapter, because the fix is always one of exactly two options:

    -- Option 1: add the column to GROUP BY (now every industry+name combo is its own group)
    SELECT name, industry, AVG(funding_usd) FROM dbo.Startup GROUP BY name, industry;
    
    -- Option 2: aggregate it instead (pick one representative name per group, e.g. the first alphabetically)
    SELECT industry, MIN(name) AS a_sample_name, AVG(funding_usd) FROM dbo.Startup GROUP BY industry;

    Why WHERE Can’t Filter on an Aggregate

    -- FAILS: WHERE runs before GROUP BY, so COUNT(*) doesn't exist yet at that point
    SELECT industry, COUNT(*) AS c FROM dbo.Startup WHERE COUNT(*) > 2 GROUP BY industry;
    -- Msg 147: An aggregate may not appear in the WHERE clause
    
    -- HAVING runs after grouping, so the aggregate genuinely exists by then:
    SELECT industry, COUNT(*) AS c FROM dbo.Startup GROUP BY industry HAVING COUNT(*) > 2;

    Once you internalize the logical order โ€” rows are filtered (WHERE), then collapsed into groups (GROUP BY), and only then do aggregate values exist to filter on (HAVING) โ€” both of these rules stop feeling arbitrary and start feeling obvious.

    Combining WHERE and HAVING in the Same Query

    -- WHERE excludes rows before grouping; HAVING excludes groups after
    SELECT industry, AVG(funding_usd) AS avg_funding
    FROM dbo.Startup
    WHERE founded_year >= 2019       -- only consider startups founded 2019+
    GROUP BY industry
    HAVING AVG(funding_usd) > 5000000; -- then only show industries averaging over $5M
    Practice tip: Whenever a GROUP BY query throws “invalid in the select list,” resist the urge to just add the offending column to GROUP BY without thinking โ€” first ask whether that actually matches the report you’re trying to build, or whether you really meant to aggregate it instead. Adding the wrong column to GROUP BY silently changes what a “group” even means.

    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 Fundamentals, coming soon on this site.

  • SQL Server Pagination: OFFSET-FETCH and Multi-Column Sorting Explained

    SQL Server Pagination: OFFSET-FETCH and Multi-Column Sorting Explained

    Real apps rarely show every row at once. Here’s how to sort by more than one column, split results into pages the correct way, and why a well-meaning shortcut (paginating without ORDER BY) causes bugs that only show up in production under load.

    Paging Through Results(skip, take, repeat โ€” the right way)1. ORDER BYsort first โ€” city, rating DESC2. OFFSETskip the rows already shown3. FETCH NEXTgrab just this page’s rowsthe trap: duplicate values split unpredictably across pagesPROBLEMNonna’s Table ยท 4.7Sakura Grill ยท 4.7โ†’ page split is arbitraryFIXORDER BY rating DESC,restaurant_id ASCstable across every pageOFFSET-FETCH needs ORDER BY โ€”T-SQL throws a syntax error withoutit. No accidental unsorted paging.

    Multi-Column Sort

    SELECT name, city, rating FROM dbo.Restaurant
    ORDER BY city ASC, rating DESC;

    City sorts alphabetically first; within each city, restaurants sort by rating, highest first. Each column can independently be ASC (default) or DESC โ€” read it left to right as “sort by this, and within ties, sort by this next.”

    -- Sorting by an expression, not just a raw column, works too:
    SELECT name, rating, price_range FROM dbo.Restaurant
    ORDER BY rating / price_range DESC; -- crude "value for money" ranking

    You can also sort by column position (ORDER BY 3 DESC) โ€” it works, but avoid it in real code; a column reordering elsewhere in the query silently changes what you sort by, with no warning.

    OFFSET-FETCH: The Standard Way to Paginate

    SELECT name, rating FROM dbo.Restaurant
    ORDER BY rating DESC
    OFFSET 0 ROWS FETCH NEXT 3 ROWS ONLY;    -- page 1 (rows 1-3)
    
    SELECT name, rating FROM dbo.Restaurant
    ORDER BY rating DESC
    OFFSET 3 ROWS FETCH NEXT 3 ROWS ONLY;    -- page 2 (rows 4-6)

    The general formula for “page N with a page size of S” is OFFSET (N-1) * S ROWS FETCH NEXT S ROWS ONLY โ€” this is exactly the calculation a web application’s backend does every time you click “next page” on a paginated table.

    Why ORDER BY Is Non-Negotiable Here

    OFFSET 3 ROWS FETCH NEXT 3 ROWS ONLY; — no ORDER BY = undefined which rows ORDER BY rating DESC OFFSET 3 ROWS FETCH NEXT 3 ROWS ONLY; — predictable

    Without a defined sort order, SQL Server makes no guarantee about row order โ€” “skip 3, take 3” is meaningless if the underlying order can silently shift between calls. OFFSET-FETCH is actually a syntax extension of ORDER BY itself in T-SQL โ€” you cannot write it without an ORDER BY clause at all; SQL Server will raise a syntax error, which is the engine protecting you from this exact bug.

    A Subtler Pagination Bug: Ties

    -- Two restaurants both rated 4.7 could land on either side of a page boundary
    -- unpredictably, if rating is the ONLY sort column and duplicates exist.
    SELECT name, rating FROM dbo.Restaurant
    ORDER BY rating DESC
    OFFSET 0 ROWS FETCH NEXT 2 ROWS ONLY;
    
    -- Fix: add a tie-breaker column that's guaranteed unique, like the primary key
    SELECT name, rating FROM dbo.Restaurant
    ORDER BY rating DESC, restaurant_id ASC
    OFFSET 0 ROWS FETCH NEXT 2 ROWS ONLY;
    Practice tip: Any time you paginate on a column that might have duplicate values (a rating, a status, a category), add the primary key as a final tie-breaker in ORDER BY. Without it, the exact same row can appear on two different pages, or vanish between pages, as data changes underneath a multi-page scroll โ€” a real bug that’s genuinely hard to reproduce without knowing to look for it.

    Key Takeaways

    • List multiple ORDER BY columns to sort within a sort โ€” first column is primary, rest break ties
    • OFFSET-FETCH is the standard SQL Server pagination pattern: skip N rows, take the next M โ€” and T-SQL enforces that it can’t be used without ORDER BY
    • Add a unique tie-breaker column (usually the primary key) to ORDER BY whenever the sort column can have duplicate values, to keep pagination stable

    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 Fundamentals, coming soon on this site.

  • SELECT, WHERE, ORDER BY: Writing Your First Real SQL Server Queries

    SELECT, WHERE, ORDER BY: Writing Your First Real SQL Server Queries

    This is the query you’ll write more than any other, in some form, for the rest of your career. Getting a genuinely solid mental model here โ€” not just “it works” but why it works in the order it does โ€” pays off in every later chapter.

    Anatomy of a SELECT(you type it one way, SQL runs it another)FROMgrab the tableWHEREkeep matching rowsSELECTpick the columnsORDER BYsort what’s leftpredictablebut you TYPED select first โ€” SQL actually runs FROM firstSELECT * is fine forexploring โ€” never inreal app code. It breakssilently when columns change.TOP N without ORDER BY = arbitrary rows โ€”never “the first N you inserted.”

    Setup โ€” run this once to follow along:

    CREATE TABLE dbo.Restaurant (
        restaurant_id INT IDENTITY(1,1) PRIMARY KEY,
        name          NVARCHAR(100) NOT NULL,
        cuisine       NVARCHAR(50)  NOT NULL,
        city          NVARCHAR(50)  NOT NULL,
        rating        DECIMAL(2,1)  NOT NULL,
        price_range   TINYINT       NOT NULL
    );
    INSERT INTO dbo.Restaurant (name, cuisine, city, rating, price_range) VALUES
    ('Spice Route', 'Indian', 'Austin', 4.5, 2),
    ('Nonna''s Table', 'Italian', 'Austin', 4.7, 3),
    ('Sakura Grill', 'Japanese', 'Austin', 4.8, 3),
    ('Taco Libre', 'Mexican', 'Dallas', 4.3, 1);

    SELECT: Choose Your Columns

    SELECT name, cuisine, rating FROM dbo.Restaurant;
    
    -- SELECT * grabs every column โ€” fine for exploring, avoid it in real application code
    SELECT * FROM dbo.Restaurant;
    
    -- Aliasing a column for a cleaner result header
    SELECT name AS restaurant_name, rating AS star_rating FROM dbo.Restaurant;
    Why avoid SELECT * in real code: it silently breaks if someone adds or reorders columns later, pulls more data over the network than you need, and (once you reach indexing in the advanced course) can prevent the optimizer from using an efficient covering index. Naming columns explicitly costs nothing and avoids all three problems.

    WHERE: Filter the Rows

    SELECT name, rating FROM dbo.Restaurant WHERE city = 'Austin';

    WHERE is evaluated once per row, against the raw table data โ€” it runs before SELECT decides which columns to keep, which is why you can filter on a column you don’t even include in the output.

    ORDER BY, DISTINCT, and TOP

    SELECT name, rating FROM dbo.Restaurant ORDER BY rating DESC;
    SELECT DISTINCT cuisine FROM dbo.Restaurant;
    SELECT TOP 3 name, rating FROM dbo.Restaurant ORDER BY rating DESC;
    SELECT TOP 25 PERCENT name FROM dbo.Restaurant ORDER BY rating DESC;

    TOP without an ORDER BY gives you an arbitrary N rows in whatever order the engine happens to read them โ€” not “the first N you inserted,” and not stable across runs. Always pair TOP with ORDER BY when you mean “the highest/lowest N,” which is almost always what you actually want.

    The Order SQL Server Actually Processes Your Query

    FROM WHERE SELECT ORDER BY

    Even though you type SELECT first, SQL Server processes FROM โ†’ WHERE โ†’ SELECT โ†’ ORDER BY (this is called logical query processing order, and it’s the single most useful mental model for debugging “why doesn’t this work” moments for the rest of this course). That’s why a column alias defined in SELECT can’t be reused in that same query’s WHERE clause โ€” WHERE runs before SELECT even exists:

    -- This fails:
    SELECT rating * 2 AS double_rating FROM dbo.Restaurant WHERE double_rating > 8;
    -- Msg 207: Invalid column name 'double_rating'
    
    -- Because WHERE runs before the alias exists, repeat the expression instead:
    SELECT rating * 2 AS double_rating FROM dbo.Restaurant WHERE rating * 2 > 8;

    ORDER BY, on the other hand, runs last โ€” after SELECT โ€” which is exactly why it’s the one clause that can reference a column alias.

    Comments and Readability

    -- single-line comment
    /* multi-line
       comment block */
    SELECT name, rating -- inline comment on the same line
    FROM dbo.Restaurant
    WHERE city = 'Austin'; -- Austin locations only

    Key Takeaways

    • SELECT picks columns; WHERE filters rows; ORDER BY sorts the result
    • DISTINCT removes duplicate rows; TOP limits row count โ€” always pair TOP with ORDER BY, or “top” is meaningless
    • Logical execution order (FROM โ†’ WHERE โ†’ SELECT โ†’ ORDER BY) explains several “why doesn’t this work” surprises, including why WHERE can’t see a SELECT alias but ORDER BY can
    • Avoid SELECT * outside of quick exploration โ€” name your columns explicitly
    Practice tip: Add one more restaurant row of your own, then write three queries against this table from memory before moving to the next lesson: one filtered by cuisine, one sorted by price_range ascending, one using TOP to get the single highest-rated restaurant.

    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 Fundamentals, coming soon on this site.

  • LIKE, IN, BETWEEN: Advanced Filtering Patterns in SQL Server

    LIKE, IN, BETWEEN: Advanced Filtering Patterns in SQL Server

    Basic equality filters only get you so far. Here’s the rest of the filtering toolkit โ€” wildcard patterns, membership tests, ranges, and combining them all with AND/OR/NOT correctly โ€” plus a NULL-comparison bug that costs beginners hours of confused debugging because it never throws an error.

    The Filtering Toolkit(three ways into the same WHERE)LIKE ‘%pat%’wildcard pattern matchIN (a, b, c)shorthand for many ORsBETWEEN x AND yinclusive on both endsWHEREAND binds tighter than OR โ€” always parenthesize when you mix them.x = NULL is alwaysfalse โ€” zero rows, noerror. Use IS NULL.

    Comparison and Logical Operators

    SELECT name FROM dbo.Restaurant WHERE rating >= 4.5;
    SELECT name FROM dbo.Restaurant WHERE city = 'Austin' AND rating > 4.0;
    SELECT name FROM dbo.Restaurant WHERE cuisine = 'Indian' OR cuisine = 'Japanese';
    SELECT name FROM dbo.Restaurant WHERE NOT (city = 'Dallas');

    Operator Precedence: A Real Beginner Trap

    -- Looks like "Austin restaurants, plus anything highly rated" โ€” but AND binds tighter than OR:
    SELECT name FROM dbo.Restaurant
    WHERE city = 'Austin' OR cuisine = 'Italian' AND rating > 4.5;
    -- Actually means: city = 'Austin' OR (cuisine = 'Italian' AND rating > 4.5)
    
    -- Use explicit parentheses to say what you actually mean:
    SELECT name FROM dbo.Restaurant
    WHERE (city = 'Austin' OR cuisine = 'Italian') AND rating > 4.5;
    Common mistake: Mixing AND and OR without parentheses. SQL follows standard operator precedence (AND binds tighter than OR, same as multiplication binds tighter than addition in arithmetic) โ€” when in doubt, add parentheses. They cost nothing and remove all ambiguity, for you and for anyone reading your query later.

    IN: A Cleaner Way to Write Multiple ORs

    SELECT name FROM dbo.Restaurant
    WHERE cuisine IN ('Indian', 'Japanese', 'Italian');
    
    -- NOT IN excludes a list โ€” but see the NULL warning below before relying on this
    SELECT name FROM dbo.Restaurant
    WHERE cuisine NOT IN ('Mexican');

    BETWEEN: Inclusive on Both Ends

    SELECT name, rating FROM dbo.Restaurant
    WHERE rating BETWEEN 4.0 AND 4.5;
    -- Equivalent to: rating >= 4.0 AND rating <= 4.5 โ€” both endpoints are included

    LIKE: Pattern Matching

    Wildcard Matches
    % Any sequence of characters (zero or more)
    _ Exactly one character
    [abc] Any single character in the set
    [^abc] Any single character NOT in the set
    SELECT name FROM dbo.Restaurant WHERE name LIKE 'T%';        -- starts with T
    SELECT name FROM dbo.Restaurant WHERE name LIKE '%Grill%';    -- contains "Grill" anywhere
    SELECT name FROM dbo.Restaurant WHERE name LIKE '_a%';        -- 2nd letter is 'a'
    SELECT name FROM dbo.Restaurant WHERE name LIKE '[ST]%';      -- starts with S or T
    Performance preview: A pattern starting with % (like '%Grill%' or '%Grill') can't use a standard index efficiently โ€” SQL Server has to scan every row, because it can't know in advance where a match might start. A pattern anchored at the start ('Grill%') can. You'll see exactly why once you reach indexing in the advanced course โ€” for now, just know that "starts with" is cheap and "contains" is comparatively expensive at scale.

    The NULL Trap

    WHERE price_range = NULL  โ†’  always returns ZERO rows, no error WHERE price_range IS NULL  โ†’  correct

    NULL means "unknown," and SQL uses three-valued logic โ€” unknown = unknown evaluates to unknown, not true. WHERE column = NULL silently returns zero rows every single time, with no error to tip you off. Always use IS NULL / IS NOT NULL instead.

    NULL's Sequel: The NOT IN Trap

    -- If ANY value in the list (or subquery result) is NULL, the ENTIRE NOT IN silently
    -- matches zero rows โ€” not an error, just wrong results:
    SELECT name FROM dbo.Restaurant
    WHERE cuisine NOT IN (SELECT cuisine FROM dbo.Restaurant WHERE cuisine IS NULL OR city = 'Dallas');
    -- If that subquery returns even one NULL alongside real values, this returns nothing at all.
    
    -- Safer: explicitly exclude NULLs from the list first, or use NOT EXISTS instead (Chapter 5)
    SELECT name FROM dbo.Restaurant r
    WHERE NOT EXISTS (
        SELECT 1 FROM dbo.Restaurant r2 WHERE r2.city = 'Dallas' AND r2.cuisine = r.cuisine
    );
    Practice tip: This NOT IN + NULL interaction is a genuinely famous SQL gotcha โ€” it has caused real production bugs at companies with otherwise experienced teams. You'll meet NOT EXISTS properly once subqueries are covered in Chapter 5; for now, just remember that NOT IN and NULL don't mix safely.

    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 Fundamentals, coming soon on this site.

  • NOT NULL and DEFAULT in SQL Server: Your First Line of Data Defense

    NOT NULL and DEFAULT in SQL Server: Your First Line of Data Defense

    Two lightweight rules you can put directly on a column, before the full constraint system (primary keys, foreign keys, CHECK) enters the picture in Chapter 6. Small as they look, they prevent an enormous share of real-world data-quality bugs โ€” the kind that surface as a mysterious blank field in a report three months later.

    NOT NULL & DEFAULT(two rules, one column at a time)NOT NULLrejects the INSERT if thevalue is missing entirelyDEFAULTauto-fills a value whenone isn’t providedINSERT INTO Support_Ticket (subject) VALUES (‘Cannot log in’);subject: ‘Cannot log in’ (yours)status: ‘open’ ยท priority: 2 ยท created_at: now() ยท ticket_guid: a fresh GUIDโ†‘ all four filled in automatically by DEFAULT โ€” you never mentioned themBackfill THEN constrain:UPDATE … WHERE x IS NULLbefore ALTER … NOT NULL.

    What NULL Actually Means

    NULL isn’t zero, an empty string, or “false” โ€” it specifically means unknown / not applicable / not yet provided. This has real, non-obvious consequences: NULL = NULL evaluates to unknown, not true, which is why you can’t write WHERE middle_name = NULL and must instead write WHERE middle_name IS NULL. Any arithmetic or string concatenation touching a NULL also produces NULL โ€” 5 + NULL is NULL, not 5. This single fact explains a large share of “why is my total wrong” bugs beginners hit later with SUM and string building.

    The Two Rules

    • NOT NULL โ€” forces every row to have a real value in that column; rejects the insert otherwise
    • DEFAULT โ€” auto-fills a value when one isn’t provided in the INSERT (a literal, or the result of a function call)

    Seeing Both in Action

    CREATE TABLE dbo.Support_Ticket (
        ticket_id    INT IDENTITY(1,1) PRIMARY KEY,
        subject      NVARCHAR(200) NOT NULL,
        status       NVARCHAR(20)  NOT NULL DEFAULT 'open',
        priority     TINYINT       NOT NULL DEFAULT 2,
        created_at   DATETIME2     NOT NULL DEFAULT SYSDATETIME(),
        ticket_guid  UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID()
    );
    
    -- status, priority, created_at, and ticket_guid all fill themselves in automatically:
    INSERT INTO dbo.Support_Ticket (subject) VALUES ('Cannot log in');
    SELECT * FROM dbo.Support_Ticket;

    DEFAULT isn’t limited to literal values โ€” SYSDATETIME() and NEWID() above are function calls, evaluated fresh at insert time for every row. This is the standard way to auto-timestamp a row or generate a GUID without the application needing to supply either.

    Adding NOT NULL to a Column That Already Has Data

    This trips up almost everyone the first time: you can’t simply tighten an existing nullable column if any row already has NULL in it.

    -- Table already has rows, some with NULL priority
    ALTER TABLE dbo.Support_Ticket ALTER COLUMN priority TINYINT NOT NULL;
    -- Msg 515: Cannot insert the value NULL into column 'priority' ...
    
    -- The real fix: backfill first, then tighten
    UPDATE dbo.Support_Ticket SET priority = 2 WHERE priority IS NULL;
    ALTER TABLE dbo.Support_Ticket ALTER COLUMN priority TINYINT NOT NULL;

    This exact two-step pattern โ€” backfill, then constrain โ€” is how real migrations tighten a loosely-defined column once you’ve decided it should never be empty going forward.

    Handling NULLs You Already Have: ISNULL and COALESCE

    Sometimes a column legitimately should allow NULL (a customer’s optional middle name), but you still need a sensible display value when querying it:

    SELECT subject, ISNULL(status, 'unknown') AS status FROM dbo.Support_Ticket;
    
    -- COALESCE takes any number of arguments, returns the first non-NULL one
    SELECT COALESCE(preferred_name, first_name, 'Guest') AS display_name FROM dbo.Customer;

    ISNULL is SQL-Server-specific and takes exactly two arguments; COALESCE is ANSI-standard, works across database engines, and accepts any number of fallback values โ€” generally the better default choice unless you have a specific reason to use ISNULL.

    What Happens Without Them

    No constraints subject: NULL status: NULL NOT NULL + DEFAULT subject: required, rejected if empty status: auto-fills ‘open’

    Without these two simple rules, incomplete or nonsensical rows slip in silently โ€” a support ticket with no subject, an order status that’s blank instead of a real state. You end up writing defensive checks in application code that the database could have enforced for free, and that check is only as good as every application and every developer remembering to write it, every single time. A NOT NULL constraint never forgets.

    Practice tip: When designing a table, default every column to NOT NULL and only relax it to nullable when you can name a real scenario where the value is genuinely unknown (a middle name, an optional phone number). This “NOT NULL unless proven otherwise” habit catches far more bugs than the reverse default.

    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 Fundamentals, coming soon on this site.

  • INSERT, UPDATE, DELETE in SQL Server: The Mistake That Wipes Your Table

    INSERT, UPDATE, DELETE in SQL Server: The Mistake That Wipes Your Table

    DML (Data Manipulation Language) statements change the data inside tables, not their structure. They’re also where the single most expensive beginner mistake in SQL happens โ€” and where a handful of less-obvious patterns (multi-row inserts, UPDATE with a JOIN, the OUTPUT clause) separate someone who can write basic DML from someone who’s actually fluent.

    INSERT ยท UPDATE ยท DELETE(and the missing WHERE that costs careers)INSERTadds new rowsUPDATEchanges existing rowsDELETEremoves rows, foreversame WHERE clause. wildly different stakes:UPDATE Employee SET salary=salary*1.05WHERE last_name=’Sharma’;exactly one row changesUPDATE Employee SET salary=0;— no WHERE clause at all๐Ÿ˜ฑ EVERY row. No undo.TRUNCATE isn’t DELETE:no WHERE, resets IDENTITY,skips triggers entirely.safe habit: SELECT the same WHERE first, then run the UPDATE

    INSERT: One Row, Many Rows, or From Another Query

    -- Single row
    INSERT INTO dbo.Employee (first_name, last_name, hire_date, salary)
    VALUES ('Priya', 'Sharma', '2024-03-01', 75000.00);
    
    -- Multiple rows in one statement โ€” one round trip instead of three
    INSERT INTO dbo.Employee (first_name, last_name, hire_date, salary)
    VALUES
        ('Alex', 'Chen', '2024-04-10', 68000.00),
        ('Jordan', 'Lee', '2024-05-02', 71000.00),
        ('Sam', 'Patel', '2024-05-15', 69500.00);
    
    -- INSERT ... SELECT โ€” copy rows from another table/query, no VALUES needed
    INSERT INTO dbo.Employee_Archive (first_name, last_name, hire_date, salary)
    SELECT first_name, last_name, hire_date, salary
    FROM dbo.Employee
    WHERE hire_date < '2020-01-01';

    INSERT ... SELECT is one of the most-used patterns in real T-SQL โ€” archiving old rows, seeding a new table from an existing one, or building the exact kind of synthetic test data later chapters use for performance work.

    UPDATE and DELETE: The Basics

    UPDATE dbo.Employee
    SET salary = salary * 1.05
    WHERE last_name = 'Sharma';
    
    DELETE FROM dbo.Employee
    WHERE employee_id = 2;
    
    TRUNCATE TABLE dbo.Employee; -- wipes ALL rows instantly, resets IDENTITY

    UPDATE Driven by Another Table

    A pattern beginners often don't discover for a while: updating one table based on values in another, using a JOIN directly inside the UPDATE:

    UPDATE e
    SET e.salary = e.salary * 1.10
    FROM dbo.Employee e
    JOIN dbo.Department d ON d.department_id = e.department_id
    WHERE d.name = 'Engineering';

    This gives every Engineering employee a 10% raise in one statement, without needing to first SELECT the matching IDs into a list. You'll get comfortable with the JOIN syntax itself in Chapter 5 โ€” file this pattern away for later.

    DELETE vs TRUNCATE: Not Interchangeable

    DELETE TRUNCATE
    WHERE clause Supported โ€” delete a subset Not allowed โ€” always removes every row
    Logging Logs each row individually Minimally logged โ€” much faster on large tables
    IDENTITY counter Unaffected โ€” next insert continues numbering Reset back to the seed value
    Triggers Fires any DELETE triggers Does not fire DELETE triggers
    Foreign keys Works even if referenced by another table (row by row) Fails if any other table has a foreign key pointing to this one

    Rule of thumb: reach for TRUNCATE only when you genuinely mean "empty this entire table and I don't care about triggers or per-row logging" โ€” typically scratch/staging tables. For anything with a WHERE clause, or that has triggers or dependent foreign keys, DELETE is the only option anyway.

    The Mistake That Costs Careers

    UPDATE Employee SET salary = 0 WHERE employee_id = 2; โœ… Updates exactly one row UPDATE Employee SET salary = 0; โŒ Zeroes out EVERY row in the table โ€” no confirmation, no undo

    Without a WHERE clause, UPDATE and DELETE apply to every row in the table. Always verify your WHERE clause as a SELECT first:

    -- Step 1: verify what you're about to change
    SELECT * FROM dbo.Employee WHERE last_name = 'Sharma';
    
    -- Step 2: only then run the UPDATE with the identical WHERE clause
    UPDATE dbo.Employee SET salary = salary * 1.05 WHERE last_name = 'Sharma';

    A Safer Habit: OUTPUT and Transactions

    Two techniques that make destructive DML meaningfully safer in practice. First, OUTPUT shows you exactly what changed, immediately:

    UPDATE dbo.Employee
    SET salary = salary * 1.05
    OUTPUT deleted.employee_id, deleted.salary AS old_salary, inserted.salary AS new_salary
    WHERE last_name = 'Sharma';

    Second, wrap risky DML in an explicit transaction so a mistake is one ROLLBACK away instead of permanent:

    BEGIN TRAN;
    UPDATE dbo.Employee SET salary = 0; -- oops, forgot the WHERE clause
    SELECT * FROM dbo.Employee; -- immediately obvious something's wrong
    ROLLBACK; -- undone, no harm done
    Practice tip: For any UPDATE/DELETE you're not fully confident in outside of a script you've tested, run it inside BEGIN TRAN ... ROLLBACK first as a dry run. Only re-run with COMMIT once the row count and OUTPUT look exactly right.

    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 Fundamentals, coming soon on this site.