Category: SQL Server Fundamentals

Beginner SQL Server tutorials: data manipulation, queries, aggregate functions, multiple tables, constraints.

  • 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.

  • Tables, Views, Indexes, Procedures: A Map of Every SQL Server Database Object

    Tables, Views, Indexes, Procedures: A Map of Every SQL Server Database Object

    A database is more than tables. Here’s everything you’ll eventually build, with enough detail on what each one actually is and why it exists — not just a name to recognize — so later lessons have a real place to click into your mental map instead of feeling like brand-new territory.

    Your Database Toolkit(more than just tables)Databaseyou’ll fully master these:Tablesthe foundation everything sits onConstraintsmake bad data impossiblequick preview now — full depth in the advanced course:Viewssaved queriesIndexesfast lookupsProceduresreusable T-SQLFunctionsusable in SELECTTriggersauto side-effectsSkipping constraints doesn’t removebugs — it just moves them from“blocked” to “silent data corruption.”

    The Full Picture

    Database Tables Views Indexes Constraints Procedures Functions Triggers

    What Each One Actually Is

    Object What it is Why it exists
    Table The physical storage of rows and columns Everything else in this list either reads from, protects, or accelerates access to tables
    View A saved, named SELECT query that behaves like a virtual table Hides complex JOIN logic behind a simple name; lets you expose a restricted subset of columns without granting access to the base table
    Index An auxiliary sorted structure pointing back to table rows (conceptually, a book’s index) Turns “scan every row to find this one” into “jump almost directly to it” — the difference between milliseconds and minutes on a large table
    Constraint A rule attached to a table that SQL Server enforces on every INSERT/UPDATE (PRIMARY KEY, FOREIGN KEY, UNIQUE, CHECK, NOT NULL, DEFAULT) Makes bad data structurally impossible to insert, rather than trusting every application to validate correctly
    Stored Procedure A named, reusable, parameterized block of T-SQL, saved inside the database Reusable business logic close to the data; reduces network round-trips versus sending raw SQL from an app every time
    Function Similar to a procedure, but must return a value and can be used directly inside a SELECT/WHERE, like a built-in function Reusable calculations or lookups you can embed in ordinary queries
    Trigger Code that runs automatically in response to an INSERT/UPDATE/DELETE on a table Enforces rules or side effects (like audit logging) that can’t be expressed as a simple constraint

    A Query That Touches Several of These at Once

    This is a preview — you’re not expected to write this yet — but it’s worth seeing how these objects compose in real code:

    -- A view, built on a table with constraints already protecting its data
    CREATE VIEW dbo.vw_ActiveCustomers AS
    SELECT customer_id, name, email
    FROM dbo.Customer
    WHERE is_active = 1;
    
    -- Querying the view feels identical to querying a table
    SELECT * FROM dbo.vw_ActiveCustomers WHERE name LIKE 'A%';

    What You’ll Master First

    In the beginner track you’ll fully master tables and constraints — the foundation everything else sits on. Views, indexes, stored procedures, functions, and triggers get a first practical preview along the way (functions in Chapter 4, views and temp tables in Chapter 5), with their full deep dive — including performance implications of indexes, and writing your own procedures/triggers — reserved for the advanced, developer/DBA-focused track that follows this one.

    Why This Map Matters

    Beginners often treat SQL as “just SELECT statements.” It’s not — it’s an ecosystem, and conflating “a query” with “the whole toolkit” causes two specific real mistakes: writing the same complex JOIN logic repeatedly in application code instead of wrapping it in a view, and validating data only in the application layer instead of also using constraints — which means a bug in one app, or a direct database edit by anyone, can silently corrupt data that a CHECK constraint would have blocked for free. Knowing this map exists means you’ll reach for the right tool later instead of forcing every problem through a plain query.

    Practice tip: As you go through the rest of this course, keep a running note of which object type each new concept belongs to (“JOIN — querying across tables,” “PRIMARY KEY — a constraint”). By the capstone project in Chapter 7, that list should cover most of this map.

    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.

  • CREATE, ALTER, DROP: SQL Server DDL Explained With Examples

    CREATE, ALTER, DROP: SQL Server DDL Explained With Examples

    DDL (Data Definition Language) statements define the structure of your database — tables, columns, and their types. Every DDL statement in SQL Server takes effect immediately and, unusually among major databases, is fully transactional — you can wrap DDL in BEGIN TRAN/ROLLBACK and undo it, which most other RDBMSes don’t allow.

    Build, Change, Destroy(and how to undo any of it)wrap ANY of these in BEGIN TRAN … ROLLBACK to undoCREATEbuild something newALTERcan fail if data doesn’t fitDROPgone. no undo. ever.risk increases →CREATE TABLE IF NOT EXISTSis NOT valid T-SQL! Use:IF OBJECT_ID(…) IS NULLBEGIN … END instead.

    CREATE: Build a New Table

    CREATE TABLE dbo.Employee (
        employee_id   INT IDENTITY(1,1) PRIMARY KEY,
        first_name    NVARCHAR(50)  NOT NULL,
        last_name     NVARCHAR(50)  NOT NULL,
        hire_date     DATE          NOT NULL DEFAULT GETDATE(),
        salary        DECIMAL(10,2) NOT NULL
    );

    Notice the dbo. prefix — that’s the schema name. dbo (database owner) is the default schema every database starts with; schemas are namespaces that let you group related tables (e.g. sales.Order vs hr.Employee) and manage permissions per group. Always schema-qualify your table names in real code — unqualified names resolve against whatever the current user’s default schema happens to be, which is a subtle source of “works on my machine” bugs.

    Guarding CREATE Against Re-Running a Script

    CREATE TABLE IF NOT EXISTS dbo.Scratch_Test (id INT); -- NOT valid T-SQL!
    
    -- The actual SQL Server idiom:
    IF OBJECT_ID('dbo.Scratch_Test', 'U') IS NULL
    BEGIN
        CREATE TABLE dbo.Scratch_Test (id INT);
    END
    Common mistake: Copying CREATE TABLE IF NOT EXISTS syntax from MySQL/PostgreSQL tutorials — it’s a syntax error in T-SQL. The OBJECT_ID(...) IS NULL check above is the standard SQL Server equivalent, and you’ll see it constantly in real migration scripts.

    ALTER: Change an Existing Table

    ALTER TABLE dbo.Employee ADD email NVARCHAR(100) NULL;
    ALTER TABLE dbo.Employee ALTER COLUMN salary DECIMAL(12,2) NOT NULL;
    ALTER TABLE dbo.Employee DROP COLUMN email;

    Two things about ALTER COLUMN that surprise beginners: first, widening a type (DECIMAL(10,2) → DECIMAL(12,2), or VARCHAR(50) → VARCHAR(100)) is safe and fast. Narrowing one — or changing NULL to NOT NULL on a column that already has NULL values — fails outright if existing data can’t fit the new definition. SQL Server checks every existing row before allowing the change.

    -- This fails if any existing row has a NULL email:
    ALTER TABLE dbo.Employee ALTER COLUMN email NVARCHAR(100) NOT NULL;
    -- Msg 515: Cannot insert the value NULL into column 'email' ...
    
    -- The real-world fix: clean the data first, then tighten the constraint
    UPDATE dbo.Employee SET email = 'unknown@example.com' WHERE email IS NULL;
    ALTER TABLE dbo.Employee ALTER COLUMN email NVARCHAR(100) NOT NULL;

    Renaming Things (It’s Not ALTER)

    Unlike some databases, T-SQL doesn’t rename objects through ALTER — it uses a dedicated system procedure:

    EXEC sp_rename 'dbo.Employee.email', 'contact_email', 'COLUMN';
    EXEC sp_rename 'dbo.Employee', 'Staff';
    Caution: sp_rename does not update any views, stored procedures, or application code referencing the old name — it only changes the object’s internal metadata name. Renaming a production table is a bigger operation than it looks.

    DROP: Permanently Remove an Object

    DROP TABLE IF EXISTS dbo.Scratch_Test;

    Unlike CREATE TABLE IF NOT EXISTS, DROP TABLE IF EXISTS genuinely is valid modern T-SQL (SQL Server 2016+) — the asymmetry is just a quirk of which syntax Microsoft added and when.

    The Danger Zone, Visualized

    CREATE / ALTER Safe, reversible-ish DROP TABLE Data + structure, gone. No undo.

    DROP TABLE deletes the table and every row in it, permanently, with no confirmation prompt. Always double-check you’re connected to the right database (SELECT DB_NAME();) before running DROP anywhere near a real environment.

    DDL Is Transactional — Use It

    Because DDL participates in transactions in SQL Server, you can test a risky structural change safely:

    BEGIN TRAN;
    
    ALTER TABLE dbo.Employee DROP COLUMN salary;
    SELECT * FROM dbo.Employee; -- confirm it looks right
    
    ROLLBACK; -- changed your mind — salary column is back, nothing happened
    -- or: COMMIT; -- to make it permanent
    Practice tip: Get in the habit of wrapping any ALTER/DROP you’re not 100% sure about in BEGIN TRAN, checking the result, then COMMIT or ROLLBACK. This one habit prevents most “oops, wrong table” DDL incidents.

    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.

  • What Is a Relational Database? RDBMS Concepts Explained for Developers

    What Is a Relational Database? RDBMS Concepts Explained for Developers

    Before writing a single SELECT statement, you need the mental model. Get this right and everything else in SQL Server clicks into place much faster — most beginner confusion later in this course traces back to a shaky version of what’s covered here.

    The Relational Mental Modelthe whole thing = a Tablecustomer_idnameemail1Alicealice@co.com2Bobbob@co.com3Caracara@co.comRow = one customerColumn = one propertySchema = the blueprint: which tables exist, their columns,types, and the rules connecting them.SQL tables have NOguaranteed row order.Add ORDER BY if ordermatters — always.

    Tables, Rows, and Columns

    A relational database stores data in tables — grids of rows and columns, like a spreadsheet, except every row follows the same strict structure and tables can reference each other. SQL Server is a Relational Database Management System (RDBMS): the software that stores, protects, and lets you query that data.

    • Table — a named collection of rows with the same columns (e.g. Customer)
    • Row (record, or formally a tuple) — one entity: one customer, one order
    • Column (field/attribute) — one property every row has: name, email
    • Schema — the overall structure: which tables exist, their columns, types, and the rules connecting them

    Why “Relational,” Specifically — The Math Behind the Name

    The term comes from set theory: a table is mathematically a relation — a set of tuples (rows). This isn’t just trivia; it explains real SQL Server behavior. A true mathematical set has no inherent order and no duplicate members — which is exactly why a SQL table has no guaranteed row order unless you explicitly add ORDER BY, and why operations like UNION (versus UNION ALL) exist specifically to remove duplicates, echoing set semantics. Beginners who assume “the rows come back in the order I inserted them” get bitten by this constantly — SQL Server makes no such promise, ever.

    How Tables Relate to Each Other

    Customer customer_id (PK) name email Order order_id (PK) customer_id (FK) amount 1-to-many

    The “relational” part means tables relate to each other through shared values, not through nested/embedded structure like you’d see in a document database (MongoDB) or a spreadsheet with merged tabs. A customer_id in the Order table points back to a row in Customer — that’s a foreign key reference, formalized fully in Chapter 5. This is fundamentally different from how a NoSQL document store would model the same data (embedding the customer’s info directly inside every order document, duplicated across orders) — the relational approach trades some query complexity (you must JOIN to see combined data) for zero duplication and guaranteed consistency.

    The Three Classic Relationship Shapes

    Shape Example How it’s modeled
    One-to-many One customer has many orders A foreign key on the “many” side (Order.customer_id) pointing to the “one” side’s primary key
    Many-to-many Many students enroll in many courses A separate junction/bridge table (e.g. Enrollment) holding two foreign keys, one to each side
    One-to-one One employee has one parking permit record A foreign key on one side with a UNIQUE constraint added — rare in practice, often just merged into one table instead

    You’ll build a real one-to-many relationship yourself starting in Chapter 5, and the full JOIN toolkit for querying across them right after.

    OLTP vs OLAP, in More Than One Paragraph

    SQL Server is usually used two ways:

    • OLTP (Online Transaction Processing) — lots of small, fast reads/writes, like an e-commerce checkout or a support-ticket system. Schema is normalized (Chapter 6) to avoid duplicate/inconsistent data, since data changes constantly.
    • OLAP (Online Analytical Processing) — fewer, much heavier queries that aggregate huge amounts of history for reporting and dashboards. Schemas here are often deliberately denormalized for read speed, since the data is mostly historical and rarely changes.

    Beginners almost always start with OLTP-style querying and design, which is the foundation either way — you can’t design a good reporting schema until you understand why the transactional schema it’s summarizing looks the way it does.

    Practice tip: Before the next lesson, sketch (on paper is fine) a Customer/Order-style relationship for something you actually use — a gym membership app, a recipe site, a to-do list with categories. Identify which side is “one” and which is “many.” This is the exact instinct Chapter 5 builds on.

    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.