SQLServerCentral Article

Five SQL Server 2022 T-SQL Functions You Should Be Using Right Now

,

I upgraded our primary OLTP box to SQL Server 2022 about eight months ago, mostly for the Query Store plan-forcing improvements. The T-SQL additions were supposed to be a side dish. They turned out to be the reason half my team is finally happy with their code reviews. Below are the five 2022-era functions I actually reach for every week, what they replaced in our codebase, and the results you can expect when you run them. None of this is theoretical — every example here is a sanitized version of something I ripped out of a production query in the last quarter.

1. GENERATE_SERIES(): killing the tally table

We had a user-defined tally table (dbo.Numbers, one million rows) that every reporting proc joined against to fill date gaps. It cost us roughly 1.4 GB of buffer pool on a server where buffer pool was already tight. GENERATE_SERIES lets me drop it.

SELECT value FROM GENERATE_SERIES(1, 5);

 

The results show a table with rows from 1 to 5:

value
-----
1
2
3
4
5

The real win is the date-spine pattern we use in every finance report. Imagine a sales table where some days have zero orders — we still need those days on the chart. Before 2022 I'd do a recursive CTE with a MAXRECURSION hint, or join dbo.Numbers. Now  I use this code, joining with the result from GENERATE_SERIES():

DECLARE @start date = '2025-01-01', @end date = '2025-01-07';

SELECT DATEADD(DAY, gs.value, @start) AS CalendarDate,
       ISNULL(s.OrderCount, 0) AS OrderCount
FROM   GENERATE_SERIES(0, DATEDIFF(DAY, @start, @end)) gs
LEFT JOIN (
    SELECT CAST(OrderDate AS date) AS d, COUNT(*) AS OrderCount
    FROM   Sales.Orders
    WHERE  OrderDate >= @start AND OrderDate < DATEADD(DAY, 1, @end)
    GROUP  BY CAST(OrderDate AS date)
) s ON s.d = DATEADD(DAY, gs.value, @start)
ORDER BY CalendarDate;

The results on our demo data:

CalendarDate   OrderCount
-----------   ----------
2025-01-01    142
2025-01-02    0
2025-01-03    198
2025-01-04    0
2025-01-05    0
2025-01-06    221
2025-01-07    187

A quick STATISTICS IO comparison on the full version of this query (a year of data, ~3.2M orders): the tally-table version did 18,114 logical reads against dbo.Numbers; the GENERATE_SERIES version did zero. Execution time dropped from 412 ms to 227 ms on my laptop. Not life-changing, but multiply it by the hundred reports that use this pattern and it adds up. One gotcha: GENERATE_SERIES returns bigint. If you're plugging the values into an int column downstream, cast them, or you'll get an implicit conversion warning in the plan.

2. GREATEST() and LEAST(): the end of three-branch CASE

We have a shipping table with three candidate dates per order: EstimatedShipDate, RequestedDate, and ApprovedDate. "When is the order actually committed to ship?" is the latest of the three. Our old code had two WHEN and one ELSE clauses in the CASE:

-- old way, and we had a bug in this for two years
SELECT OrderId,
       CASE WHEN EstimatedShipDate >= RequestedDate
             AND EstimatedShipDate >= ApprovedDate THEN EstimatedShipDate
            WHEN RequestedDate >= ApprovedDate     THEN RequestedDate
            ELSE ApprovedDate
       END AS CommitDate
FROM Orders;

The bug: when any column was NULL the CASE silently returned the wrong branch. Nobody noticed until a customer complained about a three-month-old order showing "ship immediately." The 2022 version:

SELECT OrderId,
       GREATEST(EstimatedShipDate, RequestedDate, ApprovedDate) AS CommitDate,
       LEAST   (EstimatedShipDate, RequestedDate, ApprovedDate) AS EarliestPromise
FROM   Orders;

Result:

OrderId  CommitDate   EarliestPromise
-------  ----------   ---------------
1001     2025-03-14   2025-03-10
1002     2025-03-18   2025-03-12
1003     NULL         NULL
1004     2025-03-22   2025-03-20
1004 2025-03-22 2025-03-20

Notice row 1003. GREATEST/LEAST return NULL if any argument is NULL, which is actually the ANSI behavior I want on a data-quality-sensitive column — it surfaces the bad row instead of hiding it inside a CASE branch. If you genuinely want to ignore NULLs, wrap each argument in ISNULL with a sentinel ('1900-01-01' for "smallest", '9999-12-31' for "largest"). Don't use COALESCE across the whole expression; you'll get the wrong answer. Where we use these now, outside date columns: finding the highest of Bid1/Bid2/Bid3 in an auction audit query, and picking the worst latency across three monitoring probes.

3. DATE_BUCKET(): time-series grouping without the DATEDIFF gymnastics

We ingest about 8 million IoT sensor readings a day into a staging table. The dashboard wants 15-minute averages. The old truncate-with-arithmetic pattern worked but nobody on the team could read it:

-- old: rounds ReadingTimestamp down to the nearest 15 minutes
SELECT DATEADD(MINUTE,
               (DATEDIFF(MINUTE, 0, ReadingTimestamp) / 15) * 15,
               0) AS BucketStart,
       AVG(Temperature) AS AvgTemp
FROM   SensorReadings
GROUP BY DATEADD(MINUTE, (DATEDIFF(MINUTE,0,ReadingTimestamp)/15)*15, 0);

The 2022 version uses DATE_BUCKET() with buckets of 15 minutes:

SELECT DATE_BUCKET(MINUTE, 15, ReadingTimestamp) AS BucketStart,
       AVG(Temperature) AS AvgTemp,
       COUNT(*)         AS Readings
FROM   SensorReadings
WHERE  ReadingTimestamp >= '2026-04-22'
  AND  ReadingTimestamp <  '2026-04-23'
GROUP  BY DATE_BUCKET(MINUTE, 15, ReadingTimestamp)
ORDER  BY BucketStart;

Result (first four rows):

BucketStart            AvgTemp  Readings
---------------------  -------  --------
2026-04-22 00:00:00    21.43    892
2026-04-22 00:15:00    21.39    901
2026-04-22 00:30:00    21.41    887
2026-04-22 00:45:00    21.48    895

The fourth parameter — origin — is where this gets powerful. Our operations team defines "day" as 06:00 to 06:00 (shift handover), not midnight. With the old pattern that took a subquery. With DATE_BUCKET it's an argument, passing in the 4th parameter as 6:00am:

SELECT DATE_BUCKET(HOUR, 24, EventTime, '2026-01-01T06:00:00') AS ShiftStart,
       COUNT(*) AS Events
FROM   Events
GROUP  BY DATE_BUCKET(HOUR, 24, EventTime, '2026-01-01T06:00:00');

Plan-shape note: DATE_BUCKET is sargable against a computed-column index on the same expression, but it is not sargable against a raw datetime column. If you want the seek, you still need the computed column + index. I lost an afternoon to that before realizing it.

4. WINDOW clause: the refactor I've been waiting ten years for

Our HR reporting proc had this pattern repeated four times in a single SELECT:

OVER (PARTITION BY DepartmentId ORDER BY Salary DESC)

Someone changed the partition to include LocationId for a one-off report, missed one of the four spots, and the numbers were wrong for a week. The WINDOW clause fixes this by letting you name the spec once:

SELECT DepartmentId,
       EmployeeName,
       Salary,
       ROW_NUMBER() OVER w AS RowNum,
       RANK()       OVER w AS SalaryRank,
       SUM(Salary)  OVER w AS RunningTotal,
       LAG(Salary)  OVER w AS PreviousSalary
FROM   Employees
WINDOW w AS (PARTITION BY DepartmentId ORDER BY Salary DESC);

We get the same results, but the code is cleaner.

DepartmentId  EmployeeName  Salary   RowNum  SalaryRank  RunningTotal  PreviousSalary
------------  ------------  -------  ------  ----------  ------------  --------------
10            Alvarez       142000   1       1           142000        NULL
10            Chen          128000   2       2           270000        142000
10            Okafor        119500   3       3           389500        128000
20            Bianchi       155000   1       1           155000        NULL
20            Duarte        131000   2       2           286000        155000

You can even layer windows:

You can even layer windows:

SELECT DepartmentId, EmployeeName, Salary,
       SUM(Salary) OVER winRows  AS RunningTotal,
       AVG(Salary) OVER winRange AS DeptAvg
FROM   Employees
WINDOW winBase  AS (PARTITION BY DepartmentId ORDER BY Salary DESC),
       winRows  AS (winBase ROWS  BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW),
       winRange AS (winBase RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING);

When we refactored that HR proc the diff was +3/-19 lines, no logic change, and a junior dev found a second bug that had been hiding in one of the duplicated OVER clauses. Heads up: SSMS 18 and older IntelliSense doesn't know about the WINDOW keyword and will underline it angrily. The query runs fine; update to SSMS 20 and the squiggles go away.

5. IS [NOT] DISTINCT FROM: the NULL-safe join I gave up on

ETL code is full of this anti-pattern because ANSI NULL semantics make a = b return UNKNOWN when either side is NULL:

-- old way, and we had three variants of it across the warehouse
ON  a.LastName = b.LastName
AND (a.MiddleName = b.MiddleName
     OR (a.MiddleName IS NULL AND b.MiddleName IS NULL))

Multiply that by every nullable column in a staging-to-target compare and your ON clause becomes unreadable. The 2022 way uses IS NOT DISTINCT FROM:

SELECT a.CustomerId AS StageId, b.CustomerId AS TargetId
FROM   Stage.Customers a
JOIN   dim.Customers   b
  ON   a.LastName   = b.LastName
  AND  a.MiddleName IS NOT DISTINCT FROM b.MiddleName
  AND  a.Suffix     IS NOT DISTINCT FROM b.Suffix;

Result on a small sample:

StageId  TargetId
-------  --------
501      1201      -- both MiddleName='Lee'
502      1202      -- both MiddleName=NULL, matched correctly
503      1203      -- both Suffix=NULL
504      NULL      -- stage has Suffix='Jr', target has NULL, correctly not matched

Row 502 is the one that matters. Under the old = comparison that row would not have matched, and your SCD Type-1 merge would have inserted a duplicate dim row every nightly run. We had exactly that bug in our customer dimension for about 14 months; the fix was a one-line change after upgrading. Performance note: IS NOT DISTINCT FROM is currently not sargable on nullable indexed columns — the optimizer won't use an index seek across it. For large warehouse merges I still pre-filter on the NOT NULL columns first and let IS NOT DISTINCT FROM handle only the nullable ones. Microsoft has a connect item open to improve this; I'll believe it when I see it in a CU.

Should you bother?

If you're on 2019 or earlier, none of this is a reason on its own to upgrade — but combined with Query Store on secondaries, parameter-sensitive plan optimization, and the IntelliSense fixes, it starts to add up. If you're already on 2022 or Azure SQL DB and you're still writing recursive CTEs to generate numbers or three-branch CASE expressions to pick a max, you're leaving readability and, in a few spots, real performance on the table. Pick the one that maps to code you already hate. Mine was DATE_BUCKET; yours is probably IS NOT DISTINCT FROM. Swap one proc over, run it in a dev environment, and compare the plan and the row count against the old version. That's the whole migration path.

Rate

(4)

You rated this post out of 5. Change rating

Share

Share

Rate

(4)

You rated this post out of 5. Change rating