Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

PL/SQL & T-SQL (Oracle and SQL Server)  Module 4 – Working with sets: temporary tables, MERGE and cursors

T-SQL temporary tables: #local and ##global

Create #temp tables in tempdb, predict which code can see a #local or ##global table, and use them to stage, clean and apply a feed of rows.

  • Intermediate
  • 15 minutes
  • Examples run with oracle-free 23.26.3.0.0, mssql 17.0.5005.3, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Create and index
  • Predict which procedures, batches and sessions can see a
  • Explain why temp tables have statistics and when their changes make a query compile again
  • Predict what a ROLLBACK does to temp-table rows and which constraints a temp table checks
  • Use temp tables to stage, de-duplicate and apply a feed

Before you start

What you’ll learn · Before you start

What you will learn

  • Create and index
  • Predict which procedures, batches and sessions can see a
  • Explain why temp tables have statistics and when their changes make a query compile again
  • Predict what a ROLLBACK does to temp-table rows and which constraints a temp table checks
  • Use temp tables to stage, de-duplicate and apply a feed

Before you start

On this page

A procedure builds #made_inside, fills it with 3 rows and returns, and the caller’s next SELECT from #made_inside fails with error 208, “Invalid object name”. The same caller can create #stage first, and every procedure it calls reads all 3 of its rows. A #local temporary table lives in tempdb for as long as the scope that created it, and that one rule decides which code can see it; a ##global one is shared by every session of the instance.

You will predict who can see a #local and a ##global table, see why temporary tables have statistics and indexes like ordinary ones, control the columns that SELECT INTO creates, learn what a ROLLBACK does to their rows, and stage, clean and apply a messy feed with them.

A #table lasts as long as the scope that created it

A table whose name starts with # is a local temporary table: A table whose name starts with one #, kept in tempdb for the session that created it and dropped when its procedure or its session ends; procedures it calls can use it.: it lives in tempdb: The SQL Server system database that holds temporary tables, table variables and the work tables of sorts and spools; it is created again, empty, every time the instance starts., only the session that created it can use it, and SQL Server drops it when its scope ends. Microsoft’s CREATE TABLE page gives the two scopes. A #table that a stored procedure creates goes away as soon as the procedure ends; the procedures it calls can use the table, but the code that called the procedure cannot. Every other #table, such as one created by a batch you type in sqlcmd, lasts until the session ends or a DROP TABLE removes it. Step through the lab script:

Interactive · message by message Who can see a #table Step through temp_scope.sql and watch which temporary tables exist in tempdb and who can read them.
Who can see a #table, step 6 of 6. Messages so far: Caller session to tempdb: CREATE #stage, 3 rows; Caller session to Called procedure: EXEC dbo.count_stage; Called procedure to tempdb: CREATE #made_inside.Caller sessionsqlcmd batchesCalled procedurenot runningtempdb#stage, then droppedCREATE #stage, 3 rowsEXEC dbo.count_stageCREATE #made_inside

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The caller creates #stage. CREATE TABLE #stage runs in the caller's session, outside any procedure, so the table belongs to the session. The INSERT copies accounts 1, 2 and 3 into it.
  2. A called procedure reads it. dbo.count_stage runs one level down. Microsoft documents that nested procedures can use a temporary table that their caller created, so its COUNT(*) finds all 3 rows.
  3. A procedure makes its own. dbo.make_inside creates #made_inside and fills it with the 3 SKUs of dbo.stock. Inside the procedure the table works like any other.
  4. The procedure ends. A local temporary table created in a procedure is dropped when the procedure finishes. Nothing in the caller's code drops it; the end of the scope does.
  5. The caller looks for it. The next batch selects from #made_inside and fails with error 208, Invalid object name. The table was never the caller's to read.
  6. #stage stays. The caller's own table is still there in the last batch, with its 3 rows, and lasts until the session ends or the script drops it, as this one does.

The script behind those steps creates both procedures and #stage, and its comments give what each statement prints:

temp_scope.sql: the caller's #stage, and a procedure's #made_inside SQL · scope/temp_scope.sql
-- Engine: SQL Server 2025 CU9 (17.0.5005.3); database lab with the practice schema. Client: sqlcmd without -b.
-- Who can see a local temporary table: a #table created by the caller is visible to the procedures it calls, and a
-- #table created inside a procedure is dropped when that procedure ends. Expected output in the comments.
SET NOCOUNT ON;
DROP TABLE IF EXISTS #stage;
GO
CREATE OR ALTER PROCEDURE dbo.count_stage
AS
BEGIN
  SET NOCOUNT ON;
  SELECT COUNT(*) AS rows_seen_by_procedure FROM #stage;            -- 3: the caller's table
END;
GO
CREATE OR ALTER PROCEDURE dbo.make_inside
AS
BEGIN
  SET NOCOUNT ON;
  CREATE TABLE #made_inside (sku varchar(12) NOT NULL);
  INSERT INTO #made_inside (sku) SELECT sku FROM dbo.stock;
  SELECT COUNT(*) AS rows_inside FROM #made_inside;                 -- 3
END;                                                                -- #made_inside is dropped here
GO
CREATE TABLE #stage (account_id int NOT NULL, balance decimal(14,2) NOT NULL);
INSERT INTO #stage (account_id, balance)
SELECT account_id, balance FROM dbo.accounts WHERE customer_id = 1 OR account_id = 3;   -- accounts 1, 2 and 3
EXEC dbo.count_stage;                                               -- 3
EXEC dbo.make_inside;                                               -- 3
GO
SELECT COUNT(*) AS rows_after_return FROM #made_inside;             -- error 208: Invalid object name '#made_inside'.
GO
SELECT COUNT(*) AS still_here FROM #stage;                          -- 3: it lasts until the session ends
DROP TABLE #stage;
DROP PROCEDURE dbo.count_stage, dbo.make_inside;
GO
temp_scope.sql, run in the lab Python · temp_scope_session.py
"""temp_scope.sql in the lab: sqlcmd runs it as sa in the database lab, on a freshly loaded practice schema."""

from mssql_session import Lab

with Lab() as lab:
    lab.sqlcmd("scope/temp_scope.sql", errors=1)

Output

$ sqlcmd -S localhost -U sa -C -d lab -i scope/temp_scope.sql
rows_seen_by_procedure
----------------------
                     3
rows_inside
-----------
          3
Msg 208, Level 16, State 1, Server lab-mssql, Line 1
Invalid object name '#made_inside'.
still_here
----------
         3

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 temp_scope_session.py

Procedures that use the caller’s table compile even though #stage does not exist when they are created: the name is resolved when the statement runs. Because several sessions may run the same procedure at once, SQL Server adds a unique suffix to each local temporary table’s name inside tempdb, which is why such names may have at most 116 characters instead of 128.

A name that starts with ## makes a global temporary table: A table whose name starts with ##, kept in tempdb and shared by every session of the instance until the session that created it ends and no statement still uses it., which every session on the instance can read and change. The table compares the two kinds:

Rule #local ##global
Who can use it the session that created it, and procedures it calls every session of the instance
Dropped when its procedure ends, or else when the session ends when the creating session has ended and no statement still uses it
Two sessions, same name each gets its own table one table, shared

The dropping rule for ## tables holds while the database-scoped option GLOBAL_TEMPORARY_TABLE_AUTO_DROP is on, its default. SQL Server reads that option from tempdb, so setting it in a user database changes nothing; with it off, a global table stays until someone drops it or the instance restarts.

Temp tables have statistics, indexes and recompiles like other tables

A temporary table is a real table in tempdb, so it can have a primary key, unique and check constraints and indexes, and SQL Server keeps statistics: What SQL Server records about the values in a column or index, such as how many rows hold each range of values, so the optimizer can estimate how many rows a step of a query returns. on its columns as it does for any table. The optimizer uses those statistics to estimate how many rows each step of a query returns, which is what lets it choose a good execution plan: The steps SQL Server compiles for running a batch or a query, such as which indexes to read and in which order to join tables; the optimizer chooses them before the statements run. More in “T-SQL batches, GO and variables” for a join with a large staged set. Microsoft’s tempdb page adds what makes them cheap: operations in tempdb are minimally logged, temporary tables are cached so that creating and dropping them is fast, and tempdb is created afresh every time SQL Server starts. SQL Server 2025 adds two tempdb options: accelerated database recovery, which once enabled (it takes a restart) rolls back work on temporary tables at once, and space resource governance, which caps how much of tempdb one workload may fill.

Statistics have a cost too. When enough rows of a table change, its statistics are marked out of date, and the next query that uses them updates them and is compiled again. Microsoft’s Statistics page gives the thresholds for compatibility level 130 and later, and a temporary table with fewer than 6 rows has a threshold of its own. This model prints them for five table sizes; press Edit, add 50 to SIZES, and run it again:

When statistics become out of date, for temporary and permanent tables Python · model/recompile_threshold.py
"""How many changes make the statistics of a table out of date: the thresholds on Microsoft's Statistics page.

From SQL Server 2016 on, at database compatibility level 130 and later, a temporary table with
fewer than 6 rows is out of date after 6 changes, and any table of up to 500 rows after 500.
Above 500 rows the threshold is MIN(500 + 0.20 * n, SQRT(1000 * n)). The next query that uses
statistics marked out of date updates them and is compiled again. Edit SIZES and run it.
"""

import math

SIZES = [3, 100, 500, 20_000, 2_000_000]


def threshold(rows, temporary):
    if temporary and rows < 6:
        return 6
    if rows <= 500:
        return 500
    return min(500 + 0.20 * rows, math.sqrt(1000 * rows))


print(f"{'rows':>11}  {'temporary table':>15}  {'permanent table':>15}")
for n in SIZES:
    temp, perm = threshold(n, True), threshold(n, False)
    print(f"{n:>11,}  {temp:>15,.0f}  {perm:>15,.0f}")

Output

       rows  temporary table  permanent table
          3                6              500
        100              500              500
        500              500              500
     20,000            4,472            4,472
  2,000,000           44,721           44,721

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 recompile_threshold.py

A temporary table of 3 rows is out of date after 6 changes, where a permanent one waits for 500. Above 500 rows both kinds use the smaller of two numbers, 500 plus a fifth of the rows or the square root of 1,000 times the rows: 4,472 changes for 20,000 rows and 44,721 for 2 million, where the square root is the smaller. A procedure that fills a small #table row by row can therefore compile some of its statements again during the run, which is the price of the good estimates.

Creating a #table and its indexes in the middle of a loop

Wrong: a procedure that creates #work, adds an index and fills it inside a loop that runs 1,000 times. Each pass changes the table, and statements that read it may be compiled again and again. Right: create the table and its indexes once, before the work, and fill it with set-based statements.

Checkpoint

Check: Who can see a temporary table · Statistics, indexes and recompiles

  1. Question 1 of 3 A batch creates #stage with 3 rows and then runs EXEC dbo.count_stage, whose only statement is SELECT COUNT(*) FROM #stage. What does the procedure return?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: 3, because a procedure can use a temporary table that its caller created

    Visibility flows downwards: called code sees its caller's #tables, never the reverse.

  2. Question 2 of 3 dbo.make_inside creates #made_inside and returns. What happens when the caller then runs SELECT * FROM #made_inside?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: Error 208, because the table was dropped when the procedure finished

    Create the #table in the caller when the caller needs the rows after the call.

  3. Question 3 of 3 A temporary table holds 3 rows. After how many changes does SQL Server mark its statistics out of date, at compatibility level 130 or later?

    Type a number.

    Show the answer: Question 3 of 3

    Answer: 6

    Temporary tables with fewer than 6 rows have a threshold of 6 changes; a permanent table of the same size waits for 500, as the model shows.

SELECT INTO copies each column’s type and nullability

There are two ways to make a temporary table. CREATE TABLE #t (…) names every column, its type, its nullability and its constraints before any row arrives. SELECT … INTO #t FROM … creates the table from a query and fills it in one statement; Microsoft’s SELECT INTO page says that each new column has the same name, data type, nullability and value as its expression in the select list. A plain identity column even brings its IDENTITY property along, unless the query has a join or a UNION, or the column is part of an expression, listed twice or read from a remote data source. Indexes, constraints and triggers are never copied, so the #acct below has no primary key until you create one. The script copies dbo.accounts and asks tempdb what it created:

select_into_types.sql: the columns that SELECT INTO derived SQL · types/select_into_types.sql
-- Engine: SQL Server 2025 CU9 (17.0.5005.3); database lab with the practice schema. Client: sqlcmd.
-- SELECT ... INTO #acct takes each column's name, type and nullability from the select list, and the IDENTITY property
-- of a plain identity column. tempdb.sys.columns shows what SQL Server derived. Expected values in the comments.
SET NOCOUNT ON;
DROP TABLE IF EXISTS #acct;
SELECT account_id,                                                      -- int NOT NULL, IDENTITY inherited
       balance,                                                         -- decimal(14,2) NOT NULL, as in dbo.accounts
       balance * 0.04                                   AS interest,    -- nullable: the result of an expression
       ISNULL(CAST(balance * 0.04 AS decimal(14,2)), 0) AS interest_fixed   -- decimal(14,2) NOT NULL, as asked for
INTO #acct
FROM dbo.accounts;
SELECT c.name, TYPE_NAME(c.user_type_id) AS type_name, c.precision, c.scale, c.is_nullable, c.is_identity
FROM tempdb.sys.columns AS c
WHERE c.object_id = OBJECT_ID(N'tempdb..#acct')
ORDER BY c.column_id;
SELECT COUNT(*) AS rows_copied FROM #acct;                              -- 4
DROP TABLE #acct;
GO
select_into_types.sql, run in the lab Python · select_into_types_session.py
"""select_into_types.sql in the lab: sqlcmd runs it as sa in the database lab, on a freshly loaded practice schema."""

from mssql_session import Lab

with Lab() as lab:
    lab.sqlcmd("types/select_into_types.sql")

Output

$ sqlcmd -S localhost -U sa -C -d lab -i types/select_into_types.sql
name           type_name precision scale is_nullable is_identity
-------------- --------- --------- ----- ----------- -----------
account_id     int              10     0           0           1
balance        decimal          14     2           0           0
interest       numeric          17     4           1           0
interest_fixed decimal          14     2           0           0
rows_copied
-----------
          4

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 select_into_types_session.py

account_id and balance arrive exactly as dbo.accounts defines them, NOT NULL included. balance * 0.04 becomes a nullable column, because SQL Server treats the result of most expressions as nullable even when every input is NOT NULL, and its precision follows the rules for multiplying decimals. ISNULL(CAST(… AS decimal(14,2)), 0) fixes both: CAST chooses the type and ISNULL with a value that is never NULL makes the column NOT NULL. The table sums up when each way of creating the table fits:

Choose When
CREATE TABLE #t the columns, keys and constraints must be exactly right, or the table is filled in several steps
SELECT … INTO #t a one-off copy of a query’s result, where the derived types are fine or are fixed with CAST and ISNULL

A ROLLBACK undoes temp-table rows, and foreign keys are skipped

Temporary tables take part in transactions like other tables. Rows inserted into a #table inside a transaction are removed by its ROLLBACK, which is the difference from table variables that the next lesson uses on purpose. Constraints work as on other tables with one exception, which Microsoft’s CREATE TABLE page states: a FOREIGN KEY on a temporary table is skipped with a warning and never enforced, and no table may reference a temporary table either. The script shows the rollback and the skipped key:

temp_rollback.sql: rows gone after ROLLBACK, a foreign key skipped SQL · rollback/temp_rollback.sql
-- Engine: SQL Server 2025 CU9 (17.0.5005.3); database lab with the practice schema. Client: sqlcmd without -b.
-- Temporary tables take part in transactions: a ROLLBACK removes the rows inserted into #pending. A FOREIGN KEY on a
-- temporary table is skipped with a warning, so nothing checks it. Expected output in the comments.
SET NOCOUNT ON;
DROP TABLE IF EXISTS #pending, #ledger_copy;
CREATE TABLE #pending (account_id int NOT NULL PRIMARY KEY, amount decimal(14,2) NOT NULL);   -- an unnamed key
BEGIN TRANSACTION;
  INSERT INTO #pending (account_id, amount) VALUES (1, 250.00), (3, -40.00);
  SELECT COUNT(*) AS rows_inside_the_transaction FROM #pending;   -- 2
ROLLBACK TRANSACTION;
SELECT COUNT(*) AS rows_after_rollback FROM #pending;             -- 0: the rows were part of the transaction
GO
CREATE TABLE #ledger_copy (
  entry_id   int NOT NULL PRIMARY KEY,
  account_id int NOT NULL REFERENCES dbo.accounts (account_id)    -- warning 1756: the FOREIGN KEY definition is skipped
);
INSERT INTO #ledger_copy (entry_id, account_id) VALUES (1, 99);   -- accepted: account 99 does not exist
SELECT COUNT(*) AS rows_nothing_checked FROM #ledger_copy;        -- 1
DROP TABLE #pending, #ledger_copy;
GO
temp_rollback.sql, run in the lab Python · temp_rollback_session.py
"""temp_rollback.sql in the lab: sqlcmd runs it as sa in the database lab, on a freshly loaded practice schema."""

from mssql_session import Lab

with Lab() as lab:
    lab.sqlcmd("rollback/temp_rollback.sql")

Output

$ sqlcmd -S localhost -U sa -C -d lab -i rollback/temp_rollback.sql
rows_inside_the_transaction
---------------------------
                          2
rows_after_rollback
-------------------
                  0
Skipping FOREIGN KEY constraint '#ledger_copy' definition for temporary table. FOREIGN KEY constraints are not enforced on local or global temporary tables.
rows_nothing_checked
--------------------
                   1

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 temp_rollback_session.py

The first SELECT counts 2 rows inside the transaction and the second counts 0 after the ROLLBACK. The second batch creates #ledger_copy with a reference to dbo.accounts, gets message 1756, “Skipping FOREIGN KEY constraint … definition for temporary table”, and then accepts a row for account 99, which does not exist.

Constraint names need care of their own. The suffix that keeps two sessions’ #stage tables apart is added to table names only, while a constraint name must be unique within its table’s schema, and every temporary table is created in tempdb’s dbo schema. A name you write yourself is therefore one name for every session of the instance:

A constraint name on a temporary table

Wrong: CREATE TABLE #pick (sku varchar(12) NOT NULL CONSTRAINT pick_pk PRIMARY KEY); run in two sqlcmd windows. The second window fails with error 2714, “There is already an object named ‘pick_pk’ in the database”, for as long as the first one keeps its #pick. Right: sku varchar(12) NOT NULL PRIMARY KEY, whose generated name differs in every session; temporary tables are the one place where this track leaves a constraint unnamed.

Scripts that create temporary tables start with DROP TABLE IF EXISTS #t;, so they can run again in a session that still holds the table from the last run.

Checkpoint

Check: CREATE TABLE or SELECT INTO · Transactions and constraints

  1. Question 1 of 2 SELECT balance * 0.04 AS interest INTO #acct FROM dbo.accounts; balance is NOT NULL. Is #acct.interest nullable?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Yes, because SQL Server treats the result of most expressions as nullable

    Wrap the expression in ISNULL(CAST(… AS decimal(14,2)), 0) to choose both its type and NOT NULL.

  2. Question 2 of 2 #pending is empty. BEGIN TRANSACTION; INSERT two rows into #pending; ROLLBACK TRANSACTION; How many rows does #pending hold now?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 0

    Temporary tables take part in transactions, so the ROLLBACK removes both rows; a table variable would keep them.

Stage, clean and apply a feed with temp tables

Temporary tables earn their place when work happens in steps that each need the result of the last. A supplier sends 6 stock rows for the practice shop: the tea price is corrected later the same day, the rice arrives once as RICE-5K and once as rice-5k with a trailing space, and a new item, toor dal, is sent twice. Step through the script’s plan:

Interactive · stage by stage A messy feed in three steps Step through the script's temporary tables and see how 6 feed rows become 2 updates and 1 insert.
A messy feed in three steps, step 5 of 5. Showing Load #feed, Clean the keys, Keep the latest, Apply in one transaction, Report. In focus: Report, fix.a bad feed is loaded againLoad #feed6 rows as they arrivedClean the keysUPPER(TRIM(sku))Keep the latest#latest: 3 rowsApply in one transaction2 updated and 1 insertedReportwhat changed

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. Load the feed as it is. The 6 rows go into #feed without any change, so the raw feed can still be read, counted and compared after the later steps have run.
  2. One spelling per key. UPPER(TRIM(sku)) turns 'rice-5k ' into RICE-5K, so the same item is never stored under two spellings, whatever the collation does when it compares them.
  3. The latest row wins. ROW_NUMBER() numbers each SKU's rows from the newest received_at, and only number 1 goes into #latest. The tea correction wins at 185.00, and the dal sent twice counts once.
  4. Apply it as one unit. An UPDATE joined to #latest changes TEA-250 and RICE-5K, and an INSERT adds DAL-1K. Both run in one transaction, so the stock never shows half a feed.
  5. Say what changed. The report counts 2 updates and 1 insert and leaves GHEE-500 alone. Because #feed is still there, a wrong count can be traced to the rows that caused it.

The script keeps the raw feed in #feed, builds #latest with one row per cleaned SKU by keeping the row that ROW_NUMBER() numbers 1 from the newest time, puts a unique index on it, and then updates and inserts into dbo.stock in one transaction:

dedupe_feed.sql: stage, clean, keep the latest and apply SQL · feed/dedupe_feed.sql
-- Engine: SQL Server 2025 CU9 (17.0.5005.3); database lab with the practice schema. Client: sqlcmd.
-- Stage, clean and apply a messy stock feed with temporary tables: load #feed, keep the latest row per cleaned SKU in
-- #latest, update and insert into dbo.stock, and report. dedupe_model.py prints the same rows. The ROLLBACK at the end
-- keeps the practice data as it was loaded.
SET NOCOUNT ON;
DROP TABLE IF EXISTS #feed, #latest;

-- Step 1: the feed as it arrived.
CREATE TABLE #feed (
  sku         varchar(12)   NOT NULL,
  item_name   nvarchar(60)  NOT NULL,
  unit_price  decimal(10,2) NOT NULL,
  on_hand     int           NOT NULL,
  received_at datetime2(0)  NOT NULL
);
INSERT INTO #feed (sku, item_name, unit_price, on_hand, received_at) VALUES
  ('TEA-250',  N'Assam tea 250 g',        180.00, 55, '2026-03-15 09:00'),
  ('TEA-250',  N'Assam tea 250 g',        185.00, 52, '2026-03-15 17:30'),   -- a later correction
  ('RICE-5K',  N'Sona masoori rice 5 kg', 420.00, 25, '2026-03-15 09:00'),
  ('rice-5k ', N'Sona masoori rice 5 kg', 430.00, 30, '2026-03-16 08:00'),   -- the same SKU, typed differently
  ('DAL-1K',   N'Toor dal 1 kg',          160.00, 40, '2026-03-16 08:00'),   -- a new item
  ('DAL-1K',   N'Toor dal 1 kg',          160.00, 40, '2026-03-16 08:00');   -- sent twice

-- Step 2: one row per cleaned SKU, the latest one, in a table with a unique index.
SELECT sku, item_name, unit_price, on_hand
INTO #latest
FROM (
  SELECT UPPER(TRIM(sku)) AS sku, item_name, unit_price, on_hand,
         ROW_NUMBER() OVER (PARTITION BY UPPER(TRIM(sku)) ORDER BY received_at DESC) AS rn
  FROM #feed
) AS ranked
WHERE rn = 1;
CREATE UNIQUE CLUSTERED INDEX latest_sku ON #latest (sku);
SELECT sku, unit_price, on_hand FROM #latest ORDER BY sku;      -- DAL-1K 160.00 40, RICE-5K 430.00 30, TEA-250 185.00 52

-- Step 3: apply it in one transaction and report what changed.
BEGIN TRANSACTION;
  UPDATE s SET s.unit_price = l.unit_price, s.on_hand = l.on_hand
  FROM dbo.stock AS s JOIN #latest AS l ON l.sku = s.sku;
  PRINT CONCAT(N'updated: ', @@ROWCOUNT);                       -- 2
  INSERT INTO dbo.stock (sku, item_name, unit_price, on_hand)
  SELECT l.sku, l.item_name, l.unit_price, l.on_hand
  FROM #latest AS l
  WHERE NOT EXISTS (SELECT 1 FROM dbo.stock AS s WHERE s.sku = l.sku);
  PRINT CONCAT(N'inserted: ', @@ROWCOUNT);                      -- 1
  SELECT sku, unit_price, on_hand FROM dbo.stock ORDER BY sku;  -- DAL-1K, GHEE-500 untouched, RICE-5K, TEA-250
ROLLBACK TRANSACTION;                                           -- the practice data stays as loaded
DROP TABLE #feed, #latest;
GO
dedupe_feed.sql, run in the lab Python · dedupe_feed_session.py
"""dedupe_feed.sql in the lab: sqlcmd runs it as sa in the database lab, on a freshly loaded practice schema."""

from mssql_session import Lab

with Lab() as lab:
    lab.sqlcmd("feed/dedupe_feed.sql")

Output

$ sqlcmd -S localhost -U sa -C -d lab -i feed/dedupe_feed.sql
sku     unit_price on_hand
------- ---------- -------
DAL-1K      160.00      40
RICE-5K     430.00      30
TEA-250     185.00      52
updated: 2
inserted: 1
sku      unit_price on_hand
-------- ---------- -------
DAL-1K       160.00      40
GHEE-500     420.00       0
RICE-5K      430.00      30
TEA-250      185.00      52

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dedupe_feed_session.py

This model follows the same rows through the three steps; press Edit, change the time of the second tea row to "15 08:00", and run it again:

What #feed and #latest hold, and what the feed changes Python · model/dedupe_model.py
"""The stock feed of dedupe_feed.sql, staged, cleaned and applied: what the script's temporary tables hold.

Step 1 keeps the feed as it arrived (#feed). Step 2 cleans each SKU as UPPER(TRIM(sku)) does
and keeps the latest row per cleaned SKU, as ROW_NUMBER() OVER (PARTITION BY ... ORDER BY
received_at DESC) = 1 does (#latest). Step 3 updates the SKUs that dbo.stock already holds and
inserts the others. Edit FEED (add a row, change a time or a price) and run it again.
"""

STOCK = {  # dbo.stock as the practice schema loads it: sku -> (unit_price, on_hand)
    "TEA-250": ("180.00", 40),
    "RICE-5K": ("420.00", 25),
    "GHEE-500": ("420.00", 0),
}

FEED = [  # sku as sent, unit_price, on_hand, received (day and time, sortable)
    ("TEA-250", "180.00", 55, "15 09:00"),
    ("TEA-250", "185.00", 52, "15 17:30"),
    ("RICE-5K", "420.00", 25, "15 09:00"),
    ("rice-5k ", "430.00", 30, "16 08:00"),
    ("DAL-1K", "160.00", 40, "16 08:00"),
    ("DAL-1K", "160.00", 40, "16 08:00"),
]

print(f"step 1, #feed as it arrived: {len(FEED)} rows")

latest, seen = {}, {}
for sku, price, on_hand, received in FEED:
    key = sku.strip().upper()
    seen[key] = seen.get(key, 0) + 1
    if key not in latest or received > latest[key][2]:
        latest[key] = (price, on_hand, received)

print(f"step 2, #latest: {len(latest)} rows, the latest per cleaned SKU")
for key in sorted(latest):
    price, on_hand, _ = latest[key]
    print(f"  {key:<9} {price:>7} {on_hand:>4}   from {seen[key]} feed rows")

print("step 3, applied to dbo.stock")
updated = inserted = 0
for key in sorted(latest):
    price, on_hand, _ = latest[key]
    if key in STOCK:
        old_price, old_on_hand = STOCK[key]
        print(f"  {key:<9} update  {old_price} {old_on_hand} -> {price} {on_hand}")
        updated += 1
    else:
        print(f"  {key:<9} insert  {price} {on_hand}")
        inserted += 1
untouched = ", ".join(sorted(set(STOCK) - set(latest))) or "none"
print(f"updated: {updated}, inserted: {inserted}, untouched: {untouched}")

Output

step 1, #feed as it arrived: 6 rows
step 2, #latest: 3 rows, the latest per cleaned SKU
  DAL-1K     160.00   40   from 2 feed rows
  RICE-5K    430.00   30   from 2 feed rows
  TEA-250    185.00   52   from 2 feed rows
step 3, applied to dbo.stock
  DAL-1K    insert  160.00 40
  RICE-5K   update  420.00 25 -> 430.00 30
  TEA-250   update  180.00 40 -> 185.00 52
updated: 2, inserted: 1, untouched: GHEE-500

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dedupe_model.py

Six rows become three: 185.00 for the tea, 430.00 and 30 bags for the rice, and the dal once. The feed updates 2 items, inserts 1 and leaves the ghee untouched. With the tea’s later row moved before the first one, 180.00 wins instead, which is why the order of a ROW_NUMBER() decides what “latest” means. Ties need a rule as well: the two dal rows share their time, so ROW_NUMBER() may number either of them 1, which is harmless only because they are identical; a feed whose tied rows differ needs a tie-breaker, such as a line number, at the end of the ORDER BY. The script’s last statement rolls the changes back, so the practice data stays as loaded.

Key idea

Stage the raw rows in one #table, clean and de-duplicate into a second one with a unique index, then change the real tables in one set-based transaction.

Checkpoint

Check: Staging and de-duplicating a feed

  1. One question The feed has TEA-250 at 180.00 received at 09:00 and at 185.00 received at 17:30. #latest keeps ROW_NUMBER() OVER (PARTITION BY sku ORDER BY received_at DESC) = 1. Which price does it keep?

    Choose one answer.

    Show the answer: One question

    Answer: 185.00, the row with the newest received_at

    The ORDER BY inside OVER defines "latest", so it must name a column that really orders the rows.

Practise: predict what each #table holds

Each question shows a few statements around a temporary table. Decide who can see it, what it contains and when it goes away, then read why each answer is right or wrong:

Practice that adapts to you

Practise this lesson

13 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 13 Which name creates a global temporary table?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: ##feed

    One # for local, two for global.

  2. Question 2 of 13 Two sessions run the same procedure at the same time, and it creates #work. What happens?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: Each session gets its own #work, because SQL Server adds a unique suffix to the name in tempdb

    The suffix is also why a local temporary table's name may have at most 116 characters.

  3. Question 3 of 13 Session A creates ##handover, session B starts reading it, and session A disconnects while B's SELECT is still running. When is ##handover dropped, with the default settings?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: When B's running statement has finished, because no statement uses the table after that

    A hand-over through a ##table works only while the creating session stays connected, or until the reader's statement ends.

  4. Question 4 of 13 What happens to tempdb when the SQL Server instance restarts?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: It is created again, empty

    Nothing that must survive belongs in a temporary table.

  5. Question 5 of 13 Why can a join with a staged #table of 50,000 rows get a better plan than the same join with a table variable on SQL Server 2017?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: The #table has statistics, so the optimizer estimates its rows from real data

    Good estimates are the main reason to stage a large set in a temporary table; the next lesson shows what changed for table variables at compatibility level 150.

  6. Question 6 of 13 A temporary table has 20,000 rows. How many changes make its statistics out of date at compatibility level 130 or later, rounded to a whole number?

    Type a number.

    Show the answer: Question 6 of 13

    Answer: 4472 (anything from 4471 to 4473 counts)

    MIN(500 + 0.20 × 20,000, SQRT(1,000 × 20,000)) = MIN(4,500, 4,472.1), so about 4,472 changes.

  7. Question 7 of 13 SELECT account_id, balance INTO #acct FROM dbo.accounts; account_id is an identity column. What is #acct.account_id?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: An identity column, because a plain identity column keeps its IDENTITY property

    Use CREATE TABLE when you do not want the copied IDENTITY property.

  8. Question 8 of 13 A procedure fills #sku_sales in three steps and later joins it on sku, which must be unique and NOT NULL. Which way of creating #sku_sales fits?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: CREATE TABLE #sku_sales with sku NOT NULL PRIMARY KEY, then three INSERT statements

    SELECT INTO suits one-off copies; a table built in steps wants CREATE TABLE.

  9. Question 9 of 13 What does SQL Server do with a FOREIGN KEY in CREATE TABLE #t?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: It skips the constraint with a warning, so it is never enforced

    Check references with a join or EXISTS when staged rows must point at real rows.

  10. Question 10 of 13 Session 1 runs CREATE TABLE #pick (sku varchar(12) NOT NULL CONSTRAINT pick_pk PRIMARY KEY); outside any transaction and keeps the table. Session 2 then runs the same statement. What happens in session 2?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: Error 2714, because the constraint name pick_pk is already taken in tempdb

    Leave constraints on temporary tables unnamed: SQL Server then generates a name that differs in every session.

  11. Question 11 of 13 The lesson's feed has 6 rows for 3 distinct SKUs once each key is cleaned. How many rows does #latest hold?

    Type a number.

    Show the answer: Question 11 of 13

    Answer: 3

    One row per cleaned SKU: tea, rice and dal.

  12. Question 12 of 13 Why does dedupe_feed.sql keep the raw rows in #feed instead of cleaning them as they are loaded?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: So a wrong result can be traced back to the rows that caused it, and the cleaning can be run again

    Each step's own table makes each step checkable.

  13. Question 13 of 13 The UPDATE of the feed succeeds and the INSERT of new items fails. Why does the script run both inside one transaction?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: So the stock never shows half a feed; the failed step can roll back the whole change

    With the error handling of the previous module, a failed step rolls back the whole feed and reports the original error.

Interview questions

Warm-up (fresher to 2–5 years): how do #local and ##global temporary tables differ?

Both live in tempdb and are dropped automatically, but they differ in who can use them and when they go. A #local table belongs to the session that created it: a procedure’s #table is dropped when the procedure ends and can be used by the procedures it calls, and any other #table lasts until the session ends. Two sessions that create #stage get two separate tables, because SQL Server adds a unique suffix to the name in tempdb. A ##global table is one table for every session on the instance; it is dropped when the session that created it has ended and no statement still uses it. Global tables suit a short hand-over between sessions; most code needs the isolation of #local tables.

Two longer questions follow, one about a report procedure that stops running in parallel and one about a scheduled job whose second step cannot find its staging table; work out your answer before you open each first step:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A report procedure creates #totals with CONSTRAINT totals_pk PRIMARY KEY, and its callers run it inside their own transactions. Since a second team started calling it, calls queue up behind each other at month end while the CPU is nearly idle. Explain what is happening and how you would change the procedure.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A scheduler runs a nightly job in two steps, each on its own database connection. Step 1 loads the day's orders into #stage; step 2 runs dbo.build_report, which reads #stage. Step 2 fails with error 208 every night, although step 1 succeeds. Explain why, and give two designs that work.

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Who can see a #table a stored procedure creates?

    The procedure itself and the procedures it calls. It is dropped as soon as the procedure ends, so the caller's next SELECT from it fails with error 208, invalid object name. Any other #table, such as one a batch creates, lasts until the session ends or a DROP removes it.

  2. Two sessions each create #stage. How many tables are there, and what does that cost the name?

    Two: SQL Server adds a unique suffix to each local temporary table's name inside tempdb, which is why such a name may have at most 116 characters instead of 128.

  3. When is a ##global temporary table dropped?

    When the session that created it has ended and no statement still uses it, while the database-scoped option GLOBAL_TEMPORARY_TABLE_AUTO_DROP is on, its default. SQL Server reads that option from tempdb, so setting it in a user database changes nothing.

  4. How many changes make a small temporary table's statistics out of date?

    Six, for a table with fewer than 6 rows, where a permanent one waits for 500. Above 500 rows both kinds use the smaller of 500 plus a fifth of the rows and the square root of 1,000 times the rows. So a procedure that fills a small #table row by row can recompile its statements during the run.

  5. What does SELECT ... INTO #t copy, and what does it not?

    Each new column keeps the name, data type, nullability and value of its expression, and a plain identity column even brings its IDENTITY property unless the query joins, unions, repeats or computes it. Indexes, constraints and triggers are never copied. Most expressions give a nullable column, which CAST and ISNULL fix.

  6. What does a ROLLBACK do to temp-table rows, and which constraint is never enforced?

    Rows inserted inside the transaction are removed by its ROLLBACK. A FOREIGN KEY on a temporary table is skipped with message 1756 and never enforced, and no table may reference a temporary table, so a staged row for an account that does not exist is accepted.

  7. Why do temporary tables leave their constraints unnamed?

    The unique suffix is added to table names only, and every temporary table is created in tempdb's dbo schema, where a constraint name must be unique. A name you write yourself is one name for the whole instance, so a second session fails with error 2714, there is already an object named that.

  8. What are the three steps of applying a messy supplier feed with temporary tables?

    Keep the raw rows in one #table, build a second with one row per cleaned key (the row ROW_NUMBER() numbers 1 from the newest time) and a unique index on it, then update and insert into the real tables in one set-based transaction. Tied rows need a tie-breaker such as a line number at the end of the ORDER BY.

Key takeaways

  • A #table created in a procedure is dropped when the procedure ends and is visible to the procedures it calls; any other #table lasts until its session ends, and a ##table is shared by every session.
  • Temporary tables live in tempdb with statistics, constraints and indexes, so the optimizer estimates them well, and a small one is marked out of date after only 6 changes.
  • SELECT INTO copies names, types and nullability from the select list, so CAST and ISNULL decide the columns it creates.
  • A ROLLBACK removes rows inserted into a temporary table, its foreign keys are never enforced, and its other constraints stay unnamed, because a constraint name gets no suffix and is shared by every session in tempdb.
  • Staging the raw feed, de-duplicating into a second table and applying it in one transaction keeps every step checkable.

Terms in this lesson

global temporary table
A table whose name starts with ##, kept in tempdb and shared by every session of the instance until the session that created it ends and no statement still uses it.
local temporary table
A table whose name starts with one #, kept in tempdb for the session that created it and dropped when its procedure or its session ends; procedures it calls can use it.
statistics
What SQL Server records about the values in a column or index, such as how many rows hold each range of values, so the optimizer can estimate how many rows a step of a query returns.
tempdb
The SQL Server system database that holds temporary tables, table variables and the work tables of sorts and spools; it is created again, empty, every time the instance starts.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.