T-SQL Math, System and Metadata Functions: ROUND, NEWID, SCOPE_IDENTITY, @@ROWCOUNT, OBJECT_ID and Catalog Views
Use SCOPE_IDENTITY or OUTPUT INTO for a new row's id; on a table with a trigger @@IDENTITY returns the id of the row the trigger inserted, and OUTPUT without INTO fails with Msg 334. With real output we cover ROUND overflowing, POWER(2, 0.5) returning 1, AVG(int), decimal precision rules, RAND and ORDER BY NEWID(), @@ROWCOUNT resetting, and a catalog view query that finds unindexed foreign keys.
Version note: The SQL queries in this post were run on SQL Server 2025 (17.0, Cumulative Update 10) with the series' Bookstore database; the PostgreSQL comparisons were run on PostgreSQL 18.7.
Guess First: Rounding 9.99 to One Decimal Place
We round a discount of 9.99 TRY, typed as DECIMAL(3,2), to one decimal place. Is the result 10.0?
SELECT ROUND(CAST(9.99 AS DECIMAL(3,2)), 1) AS overflow;
Msg 8115, Level 16, State 2, Line 1
Arithmetic overflow error converting expression to data type numeric.
An error. The result of ROUND has the same type as its input, DECIMAL(3,2): one digit before the decimal point. 10.00 does not fit that type. Price columns are usually defined wide, so you rarely see this in production; but it can show up in a narrow intermediate calculation, a staging column or a CHECK. This post is the last part of the T-SQL function catalog: math functions and integer division, generating random values, getting the id of a newly inserted row correctly, system functions such as @@ROWCOUNT, and exploring the schema with queries. The examples use the database of Bookstore, the online bookstore used as the shared example across this site's series.
ROUND, CEILING, FLOOR and Other Math Functions
SELECT ROUND(2.345, 2) AS r2, ROUND(2.345, 2, 1) AS truncated, ROUND(-2.5, 0) AS neg_half, ROUND(2.5, 0) AS pos_half,
ROUND(1234.5, -2) AS to_hundreds, ROUND(CAST(2.5 AS FLOAT), 0) AS float_half;
r2 truncated neg_half pos_half to_hundreds float_half
----- --------- -------- -------- ----------- ----------
2.350 2.340 -3.0 3.0 1200.0 3.0
ROUND(x, n) rounds halves away from zero: 2.5 → 3, -2.5 → -3. With a third argument other than 0 it does not round but truncates: 2.345 → 2.340. A negative n rounds to the left of the decimal point; 1234.5 rounded to hundreds is 1200. The scale of the result does not change: 2.345 rounded shows as 2.350, not 2.35. In PostgreSQL round(2.5::float8) returns 2; on float it rounds halves to even (banker's rounding), while on numeric it rounds away from zero like SQL Server. One more reason to do money calculations with DECIMAL/numeric on both systems.
SELECT CEILING(2.1) AS ceil_pos, CEILING(-2.1) AS ceil_neg, FLOOR(-2.1) AS floor_neg, ABS(-7) AS abs_val,
POWER(2, 0.5) AS pow_int_base, POWER(2.0, 0.5) AS pow_dec_base, SQRT(2) AS sqrt_val, SIGN(-3.5) AS sign_val, 17 % 5 AS modulo;
ceil_pos ceil_neg floor_neg abs_val pow_int_base pow_dec_base sqrt_val sign_val modulo
-------- -------- --------- ------- ------------ ------------ ------------------ -------- ------
3 -2 -3 7 1 1.4 1.4142135623730951 -1.0 2
CEILING rounds up and FLOOR rounds down; for negative numbers "up" means toward zero: CEILING(-2.1) is -2. A typical use is "how many boxes do we need" (CEILING(quantity / 1.0 / box_capacity)). The trap of POWER is that the result type comes from the first argument: POWER(2, 0.5) returned 1, because 2 is an INT and √2 = 1.414 was cut to INT. POWER(2.0, 0.5) returned 1.4, because 2.0 is DECIMAL(2,1): one decimal place. If you want the right result, make the base FLOAT or a sufficiently wide DECIMAL. Functions such as SQRT, LOG and EXP always return FLOAT.
Integer Division and Decimal Precision
SELECT 7 / 2 AS int_div, 7 / 2.0 AS dec_div, 7 % 2 AS remainder, CAST(7 AS DECIMAL(10,2)) / 3 AS dec_by_int;
int_div dec_div remainder dec_by_int
------- -------- --------- ----------
3 3.500000 1 2.333333
The quotient of two INT values is an INT and is truncated: 7 / 2 = 3. The most common case in Bookstore is the average review rating, because reviews.rating is a TINYINT and AVG returns its result in the input's type:
SELECT AVG(rating) AS avg_int, AVG(CAST(rating AS DECIMAL(5,2))) AS avg_dec, AVG(rating * 1.0) AS avg_times_one
FROM dbo.reviews
WHERE book_id = 26;
avg_int avg_dec avg_times_one
------- -------- -------------
4 4.400000 4.400000
Dune's average is 4.4, but AVG(rating) said 4; that is the difference between "4 stars" and "4.4 stars" on the book card. In PostgreSQL avg returns numeric for integer input, so this trap does not exist there. For DECIMAL operations, SQL Server computes the result's precision and scale with rules:
DECLARE @a DECIMAL(10,2) = 1234.56, @b DECIMAL(10,2) = 3;
SELECT @a * @b AS product, @a / @b AS quotient,
SQL_VARIANT_PROPERTY(@a * @b, 'Precision') AS p_prec, SQL_VARIANT_PROPERTY(@a * @b, 'Scale') AS p_scale,
SQL_VARIANT_PROPERTY(@a / @b, 'Precision') AS q_prec, SQL_VARIANT_PROPERTY(@a / @b, 'Scale') AS q_scale;
product quotient p_prec p_scale q_prec q_scale
--------- ----------------- ------ ------- ------ -------
3703.6800 411.5200000000000 21 4 23 13
In multiplication, precisions and scales add up: DECIMAL(10,2) * DECIMAL(10,2) → DECIMAL(21,4). In division the scale grows: DECIMAL(23,13). If the result would exceed 38 digits, SQL Server cuts from the scale and keeps at least 6 digits:
DECLARE @x DECIMAL(38,10) = 1, @y DECIMAL(38,10) = 3;
SELECT @x / @y AS big_div, SQL_VARIANT_PROPERTY(@x / @y, 'Scale') AS scale;
big_div scale
------- -----
.333333 6
The quotient of two numbers with 10 decimal places stopped at 6 digits. That is why making everything DECIMAL(38, x) on the idea that "wider is better" backfires in precise calculations such as exchange rates or unit prices; choose the precision you need and CAST intermediate results explicitly.
RAND, NEWID and Random Selection
RAND() is evaluated once per query; every row gets the same value. For a random number per row you need to derive a seed from NEWID(), which changes on every row:
SELECT TOP (3) book_id, RAND() AS rand_value, RAND(CHECKSUM(NEWID())) AS rand_per_row
FROM dbo.books
ORDER BY book_id;
book_id rand_value rand_per_row
------- ------------------- -------------------
1 0.57200777273679648 0.70284360746822694
2 0.57200777273679648 0.33864401226013674
3 0.57200777273679648 0.77666590896452226
The same seed gives the same value: RAND(42) called twice returned 0.71435594503450972 twice. Useful for generating repeatable data in tests.
The most recommended way to get "one random book" is ORDER BY NEWID(). The plan shows what it does:
SET SHOWPLAN_TEXT ON;
GO
SELECT TOP (1) book_id, title FROM dbo.books WHERE is_active = 1 ORDER BY NEWID();
GO
SET SHOWPLAN_TEXT OFF;
|--Sort(TOP 1, ORDER BY:([Expr1002] ASC))
|--Compute Scalar(DEFINE:([Expr1002]=newid()))
|--Clustered Index Scan(OBJECT:([sqlseries_ext_22].[dbo].[books].[PK_books]), WHERE:([sqlseries_ext_22].[dbo].[books].[is_active]=(1)))
The whole table is scanned, a GUID is generated for every row and the rows are sorted; all for a single row. With 77 books that is fine; with a few million rows it means a full scan on every call. Bookstore's home page "book of the day" also has a business rule: the same book should appear all day. Instead of randomness, we derive a deterministic position from the date:
DECLARE @day DATE = '2026-06-20';
SELECT b.book_id, b.title
FROM dbo.books AS b
WHERE b.is_active = 1
ORDER BY b.book_id
OFFSET ABS(CHECKSUM(@day)) % (SELECT COUNT(*) FROM dbo.books WHERE is_active = 1) ROWS FETCH NEXT 1 ROWS ONLY;
book_id title
------- ---------
69 Peter Pan
Peter Pan on June 20; the same query with '2026-06-21' returned Charlotte's Web (70). The result is stable for the whole day, can be cached, and the query does not sort the whole table. OFFSET still moves forward by skipping rows on large tables; in a very large catalog it is cheaper to pick a random value from the id range and take the first row with WHERE book_id >= @x, although gaps in the ids skew the distribution.
There are two ways to generate a GUID. NEWID() generates a completely random GUID. NEWSEQUENTIALID() generates increasing values on the same machine and can only be used in a column's DEFAULT:
DROP TABLE IF EXISTS dbo.guid_test;
CREATE TABLE dbo.guid_test (
id_random UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID(),
id_sequential UNIQUEIDENTIFIER NOT NULL DEFAULT NEWSEQUENTIALID(),
n INT NOT NULL
);
INSERT INTO dbo.guid_test (n) VALUES (1), (2), (3);
SELECT n, LEFT(CAST(id_random AS CHAR(36)), 18) AS random_prefix, LEFT(CAST(id_sequential AS CHAR(36)), 18) AS sequential_prefix
FROM dbo.guid_test
ORDER BY n;
n random_prefix sequential_prefix
- ------------------ ------------------
1 18FFEA6F-2B21-4400 A7554E3E-F36B-1410
2 058C7260-5D93-48C9 A8554E3E-F36B-1410
3 AACBA6DD-1960-4374 A9554E3E-F36B-1410
There is no pattern among the random GUIDs; the sequential ones increase one by one in the first byte (A7, A8, A9). If a clustered index key is a GUID, this difference matters for page splits and fragmentation; the details are in the SQL Server Derinlemesine series. Microsoft Learn gives two warnings: since the next value can be guessed, NEWSEQUENTIALID should not be used where privacy matters (for example an id visible in a URL), and after Windows restarts the values can start from a lower range. If you try to call the function directly in a query, you get "The newsequentialid() built-in function can only be used in a DEFAULT expression ..." (Msg 302). PostgreSQL 18 added uuidv7() for this job: a sortable UUID that starts with a timestamp; uuid_extract_version(uuidv7()) returns 7.
SCOPE_IDENTITY, @@IDENTITY, IDENT_CURRENT and OUTPUT
In post 15 we got the ids of newly inserted rows with OUTPUT. There are three more functions commonly used when inserting a single row, and their differences show up once a trigger is added. Let us create a trigger that writes an audit row on every insert into orders; the audit table's identity starts at 5000:
DROP TABLE IF EXISTS dbo.order_audit;
CREATE TABLE dbo.order_audit (
audit_id INT IDENTITY(5000, 1) PRIMARY KEY,
order_id INT NOT NULL,
action VARCHAR(10) NOT NULL,
audited_at DATETIME2(0) NOT NULL DEFAULT SYSUTCDATETIME()
);
GO
CREATE OR ALTER TRIGGER dbo.tr_orders_audit ON dbo.orders AFTER INSERT AS
BEGIN
SET NOCOUNT ON;
INSERT INTO dbo.order_audit (order_id, action) SELECT order_id, 'insert' FROM inserted;
END;
GO
BEGIN TRAN;
INSERT INTO dbo.orders (customer_id, ordered_at, status) VALUES (1, '2026-06-30 10:00', 'Pending');
SELECT SCOPE_IDENTITY() AS scope_identity, @@IDENTITY AS at_at_identity, IDENT_CURRENT('dbo.orders') AS ident_current_orders;
ROLLBACK;
scope_identity at_at_identity ident_current_orders
-------------- -------------- --------------------
380 5000 380
SCOPE_IDENTITY() returns the last identity value in the same scope (the same batch, procedure or trigger): the order's id, 380. @@IDENTITY returns the last identity value in the session regardless of scope: the 5000 that the trigger wrote to the audit table. Before the trigger was added, both functions returned the same value; so code that uses @@IDENTITY silently starts returning the wrong id on the day someone adds a trigger to the table. IDENT_CURRENT('dbo.orders') returns the table's last generated value regardless of session and scope; if another session is inserting orders at the same time, you may see its id. In application code, the right choice for a single row is SCOPE_IDENTITY(), and for multiple rows OUTPUT.
OUTPUT also has a trigger-related restriction. An OUTPUT returned directly to the client does not work on a table with a trigger:
BEGIN TRAN;
INSERT INTO dbo.orders (customer_id, ordered_at, status)
OUTPUT inserted.order_id, inserted.status
VALUES (1, '2026-06-30 10:00', 'Pending'), (3, '2026-06-30 10:05', 'Pending');
ROLLBACK;
Msg 334, Level 16, State 1, Line 2
The target table 'dbo.orders' of the DML statement cannot have any enabled triggers if the statement contains an OUTPUT clause without INTO clause.
The fix is to write the result into a table variable with OUTPUT ... INTO and read it from there:
DECLARE @new TABLE (order_id INT, customer_id INT);
BEGIN TRAN;
INSERT INTO dbo.orders (customer_id, ordered_at, status)
OUTPUT inserted.order_id, inserted.customer_id INTO @new (order_id, customer_id)
VALUES (1, '2026-06-30 10:00', 'Pending'), (3, '2026-06-30 10:05', 'Pending');
SELECT order_id, customer_id FROM @new ORDER BY order_id;
SELECT @@TRANCOUNT AS open_transactions;
ROLLBACK;
SELECT @@TRANCOUNT AS after_rollback, IDENT_CURRENT('dbo.orders') AS ident_after_rollback;
order_id customer_id
-------- -----------
381 1
382 3
(2 rows affected)
open_transactions
-----------------
1
(1 rows affected)
after_rollback ident_after_rollback
-------------- --------------------
0 382
This error is the reason for the HasTrigger setting we saw in the Entity Framework Core series post "What's New in EF Core 7: ExecuteUpdate, ExecuteDelete, JSON Columns and TPC": EF Core uses OUTPUT when saving, and you have to tell it about tables with triggers. Note the last line too: we rolled back both transactions, but IDENT_CURRENT stayed at 382. Identity values are not given back on rollback; the next order will get 383, and 380–382 will never be used. Why gaps in identity are normal, and how SEQUENCE differs, is the topic of this series' upcoming post on everyday DDL.
@@ROWCOUNT, @@TRANCOUNT, ERROR_ Functions and Session Information
@@ROWCOUNT returns the number of rows affected by the last statement; the words "last statement" matter:
UPDATE dbo.books SET list_price = list_price WHERE category_id = 4;
IF @@ROWCOUNT = 0 PRINT 'nothing updated';
SELECT @@ROWCOUNT AS rowcount_after_if;
(4 rows affected)
rowcount_after_if
-----------------
0
The UPDATE affected 4 rows and IF read that correctly, but IF is itself a statement and reset @@ROWCOUNT. PRINT does the same: @@ROWCOUNT read after a PRINT came out as 0. If you will use the value in more than one place, put it into a variable right after the statement: DECLARE @updated INT = @@ROWCOUNT; kept the value 4. The "first SELECT, then check @@ROWCOUNT" pattern from post 15 relies on this rule. @@TRANCOUNT returns the number of open transactions; above it was 1 after BEGIN TRAN and 0 after ROLLBACK. If @@TRANCOUNT is not 0 at the end of a script, a transaction was left open.
The ERROR_ family returns values only inside a CATCH block:
BEGIN TRY
SELECT 1 / 0;
END TRY
BEGIN CATCH
SELECT ERROR_NUMBER() AS err_no, ERROR_SEVERITY() AS severity, ERROR_LINE() AS err_line, ERROR_MESSAGE() AS err_message;
END CATCH;
SELECT ERROR_NUMBER() AS outside_catch;
err_no severity err_line err_message
------ -------- -------- ---------------------------------
8134 16 2 Divide by zero error encountered.
(1 rows affected)
outside_catch
-------------
NULL
We will cover TRY...CATCH, THROW and error handling with transactions in detail in this series' upcoming post on writing scripts. Functions that tell who and where the session is are useful in log and audit records:
SELECT DB_NAME() AS db_name, SUSER_SNAME() AS login_name, USER_NAME() AS user_name, APP_NAME() AS app_name;
db_name login_name user_name app_name
---------------- ---------- --------- --------
sqlseries_ext_22 sa dbo SQLCMD
SUSER_SNAME() returns the login, USER_NAME() the user in the database; the sa login is dbo in every database. APP_NAME() comes from Application Name in the connection string; if you put a name like "Bookstore.Api" in the application's connection string, you can read from the DMVs which application runs slow queries or holds locks. This value is the client's own claim; do not use it for security decisions.
Metadata: OBJECT_ID, COL_LENGTH, Catalog Views and INFORMATION_SCHEMA
The fastest way to learn a schema is to query it. OBJECT_ID returns an object's id, or NULL if it does not exist, and checks the type with a second argument; COL_LENGTH returns a column's length in bytes:
SELECT OBJECT_NAME(OBJECT_ID('dbo.books', 'U')) AS as_table, OBJECT_ID('dbo.books', 'V') AS as_view,
OBJECT_ID('dbo.no_such_table') AS missing, COL_LENGTH('dbo.books', 'title') AS title_bytes,
COL_LENGTH('dbo.books', 'isbn') AS isbn_bytes, COL_LENGTH('dbo.books', 'no_such_column') AS missing_column;
as_table as_view missing title_bytes isbn_bytes missing_column
-------- ------- ------- ----------- ---------- --------------
books NULL NULL 400 13 NULL
title is an NVARCHAR(200); COL_LENGTH counts bytes, not characters, hence 400. IF OBJECT_ID('dbo.order_audit', 'U') IS NULL and IF COL_LENGTH('dbo.books', 'subtitle') IS NULL are the basic checks of re-runnable DDL scripts; that topic also comes in the upcoming DDL post.
For details there are two sources: SQL Server-specific catalog views (sys.tables, sys.columns, sys.indexes, sys.foreign_keys…) and the standard INFORMATION_SCHEMA views. Let us read the same column information from the latter:
SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH, IS_NULLABLE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'dbo' AND TABLE_NAME = 'books' AND COLUMN_NAME IN ('title', 'isbn', 'list_price')
ORDER BY ORDINAL_POSITION;
TABLE_NAME COLUMN_NAME DATA_TYPE CHARACTER_MAXIMUM_LENGTH IS_NULLABLE
---------- ----------- --------- ------------------------ -----------
books isbn char 13 NO
books title nvarchar 200 NO
books list_price decimal NULL NO
INFORMATION_SCHEMA gives the length in characters (200), while sys.columns.max_length gives it in bytes (400). INFORMATION_SCHEMA exists in PostgreSQL and MySQL too and is a good start for portable scripts; but it does not contain product-specific information such as indexes, partitions or compression. For those you need catalog views. The indexes of order_items and their key columns:
SELECT i.name AS index_name, i.type_desc, i.is_unique, i.is_primary_key,
STRING_AGG(c.name, ', ') WITHIN GROUP (ORDER BY ic.key_ordinal) AS key_columns
FROM sys.indexes AS i
JOIN sys.index_columns AS ic ON ic.object_id = i.object_id AND ic.index_id = i.index_id
JOIN sys.columns AS c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
WHERE i.object_id = OBJECT_ID('dbo.order_items') AND ic.is_included_column = 0
GROUP BY i.name, i.type_desc, i.is_unique, i.is_primary_key
ORDER BY i.name;
index_name type_desc is_unique is_primary_key key_columns
------------------------- ------------ --------- -------------- -----------------
PK_order_items CLUSTERED 1 1 order_item_id
UQ_order_items_order_book NONCLUSTERED 1 0 order_id, book_id
sp_help 'dbo.books' returns part of the same information in one command, but as eight separate result sets; handy for looking by hand, not for processing in a script.
A useful query for Bookstore: foreign keys without an index. SQL Server automatically creates an index for a primary key and a unique constraint, but not for a foreign key. With an unindexed foreign key, the child table is scanned end to end when a parent row is deleted or when joining:
SELECT OBJECT_NAME(fk.parent_object_id) AS table_name, fk.name AS fk_name, c.name AS fk_column,
OBJECT_NAME(fk.referenced_object_id) AS referenced_table
FROM sys.foreign_keys AS fk
JOIN sys.foreign_key_columns AS fkc ON fkc.constraint_object_id = fk.object_id
JOIN sys.columns AS c ON c.object_id = fkc.parent_object_id AND c.column_id = fkc.parent_column_id
WHERE NOT EXISTS (
SELECT 1
FROM sys.index_columns AS ic
WHERE ic.object_id = fkc.parent_object_id
AND ic.column_id = fkc.parent_column_id
AND ic.key_ordinal = 1
)
ORDER BY table_name, fk_name;
table_name fk_name fk_column referenced_table
------------ ----------------------- ------------------ ----------------
book_authors FK_book_authors_authors author_id authors
books FK_books_categories category_id categories
categories FK_categories_parent parent_category_id categories
order_items FK_order_items_books book_id books
orders FK_orders_customers customer_id customers
payments FK_payments_orders order_id orders
reviews FK_reviews_customers customer_id customers
Of the 11 foreign keys in the database, the column of 7 is not the first key column of any index. order_items.order_id is not in the list because the UQ_order_items_order_book index starts with it; book_authors.book_id is not there either, because it is the first column of the primary key. The query was written for single-column foreign keys; for multi-column foreign keys you also need to compare the column order. Which indexes should actually be added is a design decision covered in the index posts of the SQL Server Derinlemesine series; this query only produces the candidate list.
Function Summary and PostgreSQL Counterparts
I ran the expressions in the PostgreSQL column in my own PostgreSQL 18 container:
| T-SQL | NULL and edge behaviour | PostgreSQL |
|---|---|---|
ROUND(x, n [, 1]) | Input's type; Msg 8115 if it does not fit | round, trunc; rounds to even on float |
POWER(x, y) | Type of x; truncated on an INT base | power, numeric or float |
INT / INT, AVG(int) | Integer result | / integer; avg returns numeric |
RAND() | One value per query | random(), per row |
NEWID(), NEWSEQUENTIALID() | The sequential one only in DEFAULT | gen_random_uuid(), uuidv7() (18) |
SCOPE_IDENTITY() | NULL if no insert in the scope | RETURNING, lastval() |
@@ROWCOUNT | Reset by every statement | GET DIAGNOSTICS ... ROW_COUNT (PL/pgSQL) |
OBJECT_ID(name, type) | NULL if missing | to_regclass(name) |
sys.*, INFORMATION_SCHEMA | Length in bytes vs characters | pg_catalog, information_schema |
In Production
In money calculations, the choice of type comes before the choice of function: use DECIMAL, recognize INT division and AVG(int), and check the precision of intermediate results with SQL_VARIANT_PROPERTY. Round in one place and according to the business rule; rounding per line and then summing gives a different result from summing and then rounding, and it shows up on the invoice as a difference of a few kuruş.
Always get the new id with SCOPE_IDENTITY() or OUTPUT ... INTO. Even if @@IDENTITY and IDENT_CURRENT work today, a trigger added tomorrow or a concurrent session makes them return the wrong value. Do not do random selection on large tables with ORDER BY NEWID(); a deterministic selection rule or a precomputed candidate list is cheaper. Put an Application Name in the application's connection string; seeing which application opened which session saves time during an incident.
Common Pitfalls
- Getting the new id with
@@IDENTITYon a table with a trigger; you get the id of the row the trigger inserted. - Using
OUTPUTwithoutINTOon a table with a trigger (Msg 334). - Reading
@@ROWCOUNTafter anIF, aPRINTor another statement; put the value into a variable right away. - Not noticing the decimals cut off by integer operations such as
AVG(rating)and7 / 2;POWER(2, 0.5)returning 1. - The result of
ROUNDstaying in the input's type and overflowing at the edge (Msg 8115). ORDER BY NEWID()on a large table; the whole table is scanned and sorted for a single row.- Using
RAND()for a random value per row; it is evaluated once per query.
A Small Experiment
★☆☆ Compute each book's average rating with both AVG(rating) and AVG(rating * 1.0), and list the books where the two results show a different number of stars when rounded to an integer.
★★☆ Add an audit trigger to the customers table and, in a script that inserts a new customer, compare the ids obtained with SCOPE_IDENTITY(), @@IDENTITY and OUTPUT ... INTO. Run the script inside a transaction, roll it back at the end and look at what IDENT_CURRENT shows.
★★★ Extend the unindexed foreign key query so that it also evaluates multi-column foreign keys correctly: all columns of the foreign key must appear, in the same order, as the first n key columns of an index. For testing, create a small pair of tables with a two-column foreign key.
With this post we have completed T-SQL's everyday function catalog: string, date and time, conversion and NULL, math, system and metadata. In the next post we move to where these functions meet JSON: turning query results into JSON with FOR JSON, opening JSON into rows with OPENJSON, and JSON_VALUE, JSON_QUERY and JSON_MODIFY.