Back to Article List

SQL Server date functions: DATEADD, DATEDIFF, DATEPART

SQL Server date functions: DATEADD, DATEDIFF, DATEPART

Updated and tested 2 October 2026

Most date bugs in SQL Server don't raise an error. The query runs, rows come back, the number at the bottom of the report is off by a month, and nobody catches it until someone reconciles that report against an invoice. A broken join gets found in seconds. A DATEDIFF counting calendar boundaries when you wanted elapsed time can sit in a monthly summary for a year.

The second half of the trouble is version drift. DATETRUNC and DATE_BUCKET arrived in SQL Server 2022, DATEDIFF_BIG and AT TIME ZONE in 2016, EOMONTH back in 2012. Write a query on a 2022 box, deploy it to the 2019 instance nobody upgraded, and you get 'DATETRUNC' is not a recognized built-in function name.

Checking the SQL Server version takes one query and settles that argument before anyone writes anything. I picked the habit up after assuming an instance was 2019 for about three months. It had been 2022 since an upgrade nobody wrote down, and the build number was the only thing on that box telling the truth.

Every example here runs against the same three tables, which the sibling articles in this series reuse so the queries stay comparable.

CREATE TABLE dbo.customers (
    customer_id   int IDENTITY(1,1) NOT NULL PRIMARY KEY,
    company_name  nvarchar(200)  NOT NULL,
    contact_email varchar(255)   NULL,
    country_code  char(2)        NOT NULL,
    created_utc   datetime2(3)   NOT NULL DEFAULT (SYSUTCDATETIME())
);

CREATE TABLE dbo.orders (
    order_id     int IDENTITY(1,1) NOT NULL PRIMARY KEY,
    customer_id  int           NOT NULL REFERENCES dbo.customers (customer_id),
    order_date   datetime2(3)  NOT NULL,
    shipped_date datetime2(3)  NULL,
    status       varchar(20)   NOT NULL,
    total_amount decimal(12,2) NOT NULL
);

CREATE TABLE dbo.order_items (
    order_item_id int IDENTITY(1,1) NOT NULL PRIMARY KEY,
    order_id      int           NOT NULL REFERENCES dbo.orders (order_id),
    sku           varchar(40)   NOT NULL,
    quantity      int           NOT NULL,
    unit_price    decimal(12,2) NOT NULL
);

Date and time data types in SQL Server

Six types, and four of them belong in a table you're creating today. date is three bytes, spans 0001-01-01 to 9999-12-31 and carries no time component. time(n) is a time of day on its own. datetime2(n) holds both with a precision you pick, 0 to 7 fractional second digits, and datetimeoffset(n) is datetime2 with a UTC offset stapled on.

The two you inherit are datetime and smalldatetime. smalldatetime rounds to the nearest minute and starts in 1900, which makes it useless for anything with a timestamp on it. datetime is the one you'll meet in every schema written before about 2010: eight fixed bytes, a range that begins at 1753-01-01, and fractional seconds stored only in increments of .000, .003 and .007.

So I use datetime2(3) for new columns. A datetime column advertises milliseconds and then rounds every value it stores to the nearest .000, .003 or .007, while datetime2(3) hands back the millisecond you gave it. datetime2 storage runs 6 bytes for precision 0 to 2, 7 bytes for 3 and 4, and 8 bytes for 5 to 7, so datetime2(3) is a byte smaller than datetime and honest about what it holds. It also sits above datetime in the data type precedence list, so mixing the two in one expression converts the old type upward, which is the direction you want.

The 1753-01-01 floor on datetime gets explained as a calendar-reform artefact, Britain having dropped eleven days out of September 1752 and the Sybase engineers deciding they would not try to model that. Every version of that story I have read is secondhand, mine included, so treat it as folklore that happens to be widely repeated. The floor is 1753-01-01 either way.

Migrating an existing column means an ALTER TABLE ... ALTER COLUMN, and altering a column in SQL Server is not free on a table with any size to it. Indexes and constraints on the column block the statement outright, statistics you created by hand have to come off first, and the rewrite holds a schema modification lock while it runs. Budget for it.

SQL Server date functions for the current time: GETDATE and SYSDATETIME

Five functions return "now". They differ in return type and in precision.

SELECT GETDATE()            AS getdate_val,        -- datetime
       GETUTCDATE()         AS getutcdate_val,     -- datetime, UTC
       SYSDATETIME()        AS sysdatetime_val,    -- datetime2(7)
       SYSUTCDATETIME()     AS sysutcdatetime_val, -- datetime2(7), UTC
       SYSDATETIMEOFFSET()  AS sysdatetimeoffset_val;

GETDATE and GETUTCDATE are the old pair and they return datetime, so everything they hand you is snapped to that .000/.003/.007 grid. The SYS* family landed in SQL Server 2008 and returns datetime2(7), accurate to 100 nanoseconds, which is what you want for anything you'll later subtract. SYSDATETIMEOFFSET adds the server's offset from UTC.

The .000, .003 and .007 rounding on datetime

Microsoft documents this in a table on the datetime reference page. Run it yourself:

SELECT CAST('2026-08-26 23:59:59.990' AS datetime) AS a,
       CAST('2026-08-26 23:59:59.994' AS datetime) AS b,
       CAST('2026-08-26 23:59:59.998' AS datetime) AS c,
       CAST('2026-08-26 23:59:59.999' AS datetime) AS d;

You get 23:59:59.990, 23:59:59.993, 23:59:59.997 and then, for the last one, 2026-08-27 00:00:00.000. A value on 26 August became a value on 27 August. Bucket events by day off a datetime column and that row counts against the wrong day.

Storing UTC

I store UTC in datetime2(3) and convert at the presentation edge, which is why created_utc in the schema above defaults to SYSUTCDATETIME(). The alternative, datetimeoffset, keeps the original offset, and that's worth the extra bytes when the offset itself is business data (a booking made at 09:00 local time in Warsaw is a different fact from the same instant in UTC). For everything else, one clock across the whole database is simpler to reason about, and the conversion happens once, at the edge where somebody is reading a screen.

DATEADD: adding and subtracting intervals

DATEADD in SQL Server takes three arguments, DATEADD(datepart, number, date). The return type matches the type of the date you pass in. A negative number subtracts.

SELECT order_id,
       order_date,
       DATEADD(day,   30, order_date)  AS payment_due,
       DATEADD(hour,  -4, order_date)  AS four_hours_before,
       DATEADD(month,  1, order_date)  AS next_month,
       DATEADD(year,  -1, order_date)  AS same_day_last_year
FROM dbo.orders
WHERE order_id = 4821;

The dateparts it accepts are year, quarter, month, dayofyear, day, week, weekday, hour, minute, second, millisecond, microsecond and nanosecond. Each of them has abbreviations as well, so mm stands in for month and dd for day, though the full words read better in a script somebody else has to maintain. day, dayofyear and weekday all add plain days. week adds seven of them.

Two behaviours to keep in mind. A fractional number is truncated, so DATEADD(day, 1.9, @d) adds one day. Month arithmetic clamps to the end of the target month, so DATEADD(month, 1, '2026-01-31') gives 2026-02-28. A subscription that renews on the 31st therefore drifts to the 28th and stays there once you start adding to each result in turn. Add to the original anchor date every time and the 31st comes back.

DATEDIFF in SQL Server counts boundaries crossed

Most of the date bugs I have chased come back to a misreading of this function. Microsoft's wording is precise and easy to skim past: DATEDIFF "returns the count (as a signed integer value) of the specified datepart boundaries crossed" between two dates. It counts how many times the calendar ticked over between the two values. It does not measure the time that passed. The two answers agree often enough that the gap between them only shows up when it costs you something.

SELECT DATEDIFF(year, '2025-12-31 23:59:59', '2026-01-01 00:00:00') AS one_second_apart;

That returns 1. One second of elapsed time, one year of difference, because exactly one year boundary sat between the two values. The same logic gives you DATEDIFF(month, '2026-01-31', '2026-02-01') = 1 for a single day.

Age is where it bites hardest, because an age is a date difference anyone can check in their head. A customer born on 1990-12-01, evaluated on 2026-08-26, is 35 years old. DATEDIFF(year, '1990-12-01', '2026-08-26') returns 36, because 36 New Year's Days have passed. Subtract a year when the anniversary hasn't come round yet:

DECLARE @dob date = '1990-12-01';
DECLARE @asof date = '2026-08-26';

SELECT DATEDIFF(year, @dob, @asof)
       - CASE WHEN DATEADD(year, DATEDIFF(year, @dob, @asof), @dob) > @asof
              THEN 1 ELSE 0 END AS age_in_years;

It's ugly and it's correct. Wrapping it in a scalar function costs you a call per row, so I usually inline it instead.

DATEDIFF_BIG and the overflow limits

The int return type is a hard ceiling. DATEDIFF with second tops out at roughly 68 years of span, with millisecond at about 24 days, and with microsecond or nanosecond you're into minutes and seconds of range. Cross the line and you get The datediff function resulted in an overflow. The number of dateparts separating two date/time instances is too large.

-- Overflows: 1900 to 2026 in seconds exceeds an int
SELECT DATEDIFF(second, '1900-01-01', SYSUTCDATETIME());

-- Works: bigint return type, SQL Server 2016 and later
SELECT DATEDIFF_BIG(second, '1900-01-01', SYSUTCDATETIME());

DATEDIFF_BIG takes the same arguments and returns bigint. It shipped in SQL Server 2016, so it's available on everything still in support, and there's no reason to reach for the int version when you're diffing epochs.

DATEPART and DATENAME

DATEPART in SQL Server returns an int. DATENAME returns nvarchar and is language dependent. That difference decides which of the two is safe to put in a WHERE clause.

SELECT DATEPART(year,    order_date) AS yr,
       DATEPART(quarter, order_date) AS qtr,
       DATEPART(month,   order_date) AS mth,
       DATENAME(month,   order_date) AS month_name,
       DATENAME(weekday, order_date) AS day_name
FROM dbo.orders;

Now change the session language and run it again:

SET LANGUAGE French;
SELECT DATENAME(month, '2026-08-26');  -- août
SET LANGUAGE us_english;
SELECT DATENAME(month, '2026-08-26');  -- August

So a filter like WHERE DATENAME(weekday, order_date) = 'Saturday' stops matching the moment a login with a different default language runs it. The grid comes back empty and nothing is raised to tell you why. Use DATENAME for labels on a screen and DATEPART for every decision the query makes.

SET DATEFIRST, week and iso_week

DATEPART has its own language problem, in the week and weekday dateparts. Both depend on SET DATEFIRST, and the default for DATEFIRST comes from the login's language: 7 (Sunday) for us_english, 1 (Monday) for British English. Two logins, one query, different answers.

SET DATEFIRST 7;  -- Sunday is day 1
SELECT DATEPART(weekday, '2026-08-26') AS us_style;   -- 4

SET DATEFIRST 1;  -- Monday is day 1
SELECT DATEPART(weekday, '2026-08-26') AS iso_style;  -- 3

26 August 2026 is a Wednesday, and it's day 4 or day 3 depending on a session setting you probably didn't set. The stable answer is iso_week, which follows ISO 8601: weeks start on Monday and week 1 is the week containing the first Thursday of the year. It ignores DATEFIRST entirely.

SELECT DATEPART(iso_week, '2027-01-01') AS iso_wk,   -- 53
       DATEPART(year,     '2027-01-01') AS cal_year; -- 2027

1 January 2027 falls in ISO week 53 of 2026. Note that DATEPART(year, ...) still says 2027, so grouping by year and iso_week together splits that week in two. If you report on ISO weeks you need an ISO year expression as well, and that's the point where a small calendar table starts paying for itself.

EOMONTH, DATETRUNC and DATE_BUCKET

EOMONTH has been in the product since SQL Server 2012. It returns the last day of the month containing the date you give it. The optional second argument shifts the result by a whole number of months, forward on a positive value and backward on a negative one, so the previous month's closing boundary needs no arithmetic of its own.

SELECT EOMONTH('2026-02-10')     AS this_month_end,  -- 2026-02-28
       EOMONTH('2026-02-10', -1) AS prev_month_end,  -- 2026-01-31
       EOMONTH('2028-02-10')     AS leap_year_end;   -- 2028-02-29

Leap years are handled, as the third line shows.

DATETRUNC arrived in SQL Server 2022 and deleted a pile of nested arithmetic from my scripts. It truncates a date down to the start of a datepart and returns the same type it was given.

SELECT DATETRUNC(year,        order_date) AS year_start,
       DATETRUNC(quarter,     order_date) AS quarter_start,
       DATETRUNC(month,       order_date) AS month_start,
       DATETRUNC(iso_week,    order_date) AS iso_week_start,
       DATETRUNC(day,         order_date) AS midnight,
       DATETRUNC(hour,        order_date) AS hour_start
FROM dbo.orders;

It handles everything from year down to microsecond. The three it refuses are weekday, tzoffset and nanosecond. Grouping monthly revenue is now a one-liner:

SELECT DATETRUNC(month, order_date) AS month_start,
       COUNT(*)                     AS order_count,
       SUM(total_amount)            AS revenue
FROM dbo.orders
WHERE status = 'shipped'
GROUP BY DATETRUNC(month, order_date)
ORDER BY month_start;

DATE_BUCKET came in the same release and answers a different question: which fixed-width window does this timestamp fall into? Four-hour buckets, or ten-minute ones, out of a single call.

SELECT DATE_BUCKET(hour, 4, order_date) AS bucket_start,
       COUNT(*)                         AS orders_in_bucket
FROM dbo.orders
WHERE order_date >= '20260801'
  AND order_date <  '20260901'
GROUP BY DATE_BUCKET(hour, 4, order_date)
ORDER BY bucket_start;

The default origin is 1900-01-01 00:00:00.000, which happens to be a Monday, so week buckets align to Mondays without you doing anything. A fourth argument moves that origin to a fiscal year start or a shift boundary at 06:00. The number argument must be positive, the origin and the date have to be the same type, and the datepart has to be a literal, so a variable will not do. I have moved the origin exactly once, for a client whose shifts didn't line up with the calendar day, and I had to reopen the reference page to write this paragraph.

Truncating to a month or hour before SQL Server 2022

On 2019 and earlier the idiom is a DATEDIFF and DATEADD pair anchored to the integer zero, which SQL Server reads as 1900-01-01.

-- Start of month
SELECT DATEADD(month, DATEDIFF(month, 0, order_date), 0) AS month_start
FROM dbo.orders;

-- Start of hour
SELECT DATEADD(hour, DATEDIFF(hour, 0, order_date), 0) AS hour_start
FROM dbo.orders;

Count the boundaries from 1900 to the value, then add that many back to 1900. It works down to hour and no lower, because DATEDIFF(second, 0, ...) against a 2026 date blows past the int ceiling I mentioned a few sections back. Anchor to a recent date if you need second-level buckets on an older instance, or swap the anchor for DATEDIFF_BIG. The return type here is datetime, so cast it if the surrounding expression cares about precision.

Your idea deserves better hosting

24/7 support 30-day money-back guaranteeRefund covers the VPS plan on your first order. Cancel anytime
Betalingscyclus

VPS.S1

28.38 RON Save  17 %
23.64 RON Maandelijks
  • 2 vCPU AMD EPYC
  • 2 GB RAMGEHEUGEN
  • 30 GB NVMeOPSLAG
  • Ongelimiteerde bandbreedte
  • IPv4 & IPv6IPv6-ondersteuning is momenteel niet beschikbaar in Frankrijk, Finland of Nederland. inbegrepen

VPS.S3

71.01 RON Save  33 %
47.33 RON Maandelijks
  • 4 vCPU AMD EPYC
  • 6 GB RAMGEHEUGEN
  • 70 GB NVMeOPSLAG
  • Ongelimiteerde bandbreedte
  • IPv4 & IPv6IPv6-ondersteuning is momenteel niet beschikbaar in Frankrijk, Finland of Nederland. inbegrepen

VPS.S4

94.70 RON Save  20 %
75.75 RON Maandelijks
  • 6 vCPU AMD EPYC
  • 8 GB RAMGEHEUGEN
  • 80 GB NVMeOPSLAG
  • Ongelimiteerde bandbreedte
  • IPv4 & IPv6IPv6-ondersteuning is momenteel niet beschikbaar in Frankrijk, Finland of Nederland. inbegrepen

EPYC VPS.P1

42.59 RON Save  22 %
33.11 RON Maandelijks
  • 2 vCPU AMD EPYC
  • 4 GB RAMGEHEUGEN
  • 40 GB NVMeOPSLAG
  • Ongelimiteerde bandbreedte
  • IPv4 & IPv6IPv6-ondersteuning is momenteel niet beschikbaar in Frankrijk, Finland of Nederland. inbegrepen
  • Gratis auto back-upBevat één back-upslot dat je kunt instellen op dagelijks, wekelijks of maandelijks.

EPYC VPS.P2

80.49 RON Save  24 %
61.54 RON Maandelijks
  • 2 vCPU AMD EPYC
  • 8 GB RAMGEHEUGEN
  • 80 GB NVMeOPSLAG
  • Ongelimiteerde bandbreedte
  • IPv4 & IPv6IPv6-ondersteuning is momenteel niet beschikbaar in Frankrijk, Finland of Nederland. inbegrepen
  • Gratis auto back-upBevat één back-upslot dat je kunt instellen op dagelijks, wekelijks of maandelijks.

EPYC VPS.P4

142.07 RON Save  23 %
108.91 RON Maandelijks
  • 4 vCPU AMD EPYC
  • 16 GB RAMGEHEUGEN
  • 160 GB NVMeOPSLAG
  • Ongelimiteerde bandbreedte
  • IPv4 & IPv6IPv6-ondersteuning is momenteel niet beschikbaar in Frankrijk, Finland of Nederland. inbegrepen
  • Gratis auto back-upBevat één back-upslot dat je kunt instellen op dagelijks, wekelijks of maandelijks.

EPYC VPS.P5

189.45 RON Save  25 %
142.08 RON Maandelijks
  • 8 vCPU AMD EPYC
  • 16 GB RAMGEHEUGEN
  • 180 GB NVMeOPSLAG
  • Ongelimiteerde bandbreedte
  • IPv4 & IPv6IPv6-ondersteuning is momenteel niet beschikbaar in Frankrijk, Finland of Nederland. inbegrepen
  • Gratis auto back-upBevat één back-upslot dat je kunt instellen op dagelijks, wekelijks of maandelijks.

EPYC VPS.P6

284.20 RON Save  25 %
213.14 RON Maandelijks
  • 8 vCPU AMD EPYC
  • 32 GB RAMGEHEUGEN
  • 200 GB NVMeOPSLAG
  • Ongelimiteerde bandbreedte
  • IPv4 & IPv6IPv6-ondersteuning is momenteel niet beschikbaar in Frankrijk, Finland of Nederland. inbegrepen
  • Gratis auto back-upBevat één back-upslot dat je kunt instellen op dagelijks, wekelijks of maandelijks.

EPYC VPS.P7

331.57 RON Save  29 %
236.82 RON Maandelijks
  • 16 vCPU AMD EPYC
  • 32 GB RAMGEHEUGEN
  • 240 GB NVMeOPSLAG
  • Ongelimiteerde bandbreedte
  • IPv4 & IPv6IPv6-ondersteuning is momenteel niet beschikbaar in Frankrijk, Finland of Nederland. inbegrepen
  • Gratis auto back-upBevat één back-upslot dat je kunt instellen op dagelijks, wekelijks of maandelijks.

Genoa VPS.G2

118.39 RON Save  20 %
94.70 RON Maandelijks
  • 2 vCPUAMD EPYC Genoa 4e generatie 9xx4 met 3,25 GHz of vergelijkbaar, op Zen 4-architectuur. AMD EPYC G4
  • 4 GB DDR5GEHEUGEN
  • 50 GB NVMeOPSLAG
  • Ongelimiteerde bandbreedte
  • IPv4 & IPv6IPv6-ondersteuning is momenteel niet beschikbaar in Frankrijk, Finland of Nederland. inbegrepen
  • Gratis auto back-upBevat één back-upslot dat je kunt instellen op dagelijks, wekelijks of maandelijks.

Genoa VPS.G4

213.14 RON Save  22 %
165.76 RON Maandelijks
  • 4 vCPUAMD EPYC processor met dedicated vCPU cores, op enterprise serverhardware. AMD EPYC G4
  • 8 GB DDR5GEHEUGEN
  • 100 GB NVMeOPSLAG
  • Ongelimiteerde bandbreedte
  • IPv4 & IPv6IPv6-ondersteuning is momenteel niet beschikbaar in Frankrijk, Finland of Nederland. inbegrepen
  • Gratis auto back-upBevat één back-upslot dat je kunt instellen op dagelijks, wekelijks of maandelijks.

Genoa VPS.G6

426.32 RON Save  22 %
331.57 RON Maandelijks
  • 8 vCPUAMD EPYC processor met dedicated vCPU cores, op enterprise serverhardware. AMD EPYC G4
  • 16 GB DDR5GEHEUGEN
  • 200 GB NVMeOPSLAG
  • Ongelimiteerde bandbreedte
  • IPv4 & IPv6IPv6-ondersteuning is momenteel niet beschikbaar in Frankrijk, Finland of Nederland. inbegrepen
  • Gratis auto back-upBevat één back-upslot dat je kunt instellen op dagelijks, wekelijks of maandelijks.

Genoa VPS.G7

757.94 RON Save  22 %
592.13 RON Maandelijks
  • 8 vCPUAMD EPYC processor met dedicated vCPU cores, op enterprise serverhardware. AMD EPYC G4
  • 32 GB DDR5GEHEUGEN
  • 250 GB NVMeOPSLAG
  • Ongelimiteerde bandbreedte
  • IPv4 & IPv6IPv6-ondersteuning is momenteel niet beschikbaar in Frankrijk, Finland of Nederland. inbegrepen
  • Gratis auto back-upBevat één back-upslot dat je kunt instellen op dagelijks, wekelijks of maandelijks.

AMD Ryzen VPS.R1

80.49 RON Save  18 %
66.28 RON Maandelijks
  • 1 dedicated CPU AMD Ryzen 9 7950X met 4,5 GHz of vergelijkbaar, op Zen 4-architectuur. vCPU
  • 4 GB DDR5GEHEUGEN
  • 50 GB NVMeOPSLAG
  • Ongelimiteerde bandbreedte
  • IPv4 & IPv6 inbegrepen IPv6-ondersteuning is momenteel niet beschikbaar in Frankrijk, Finland of Nederland.
  • Auto back-up inbegrepen

AMD Ryzen VPS.R2

142.07 RON Save  17 %
118.39 RON Maandelijks
  • 2 dedicated CPUs AMD Ryzen 9 7950X met 4,5 GHz of vergelijkbaar, op Zen 4-architectuur. vCPU
  • 8 GB DDR5GEHEUGEN
  • 100 GB NVMeOPSLAG
  • Ongelimiteerde bandbreedte
  • IPv4 & IPv6 inbegrepen IPv6-ondersteuning is momenteel niet beschikbaar in Frankrijk, Finland of Nederland.
  • Auto back-up inbegrepen

AMD Ryzen VPS.R4

521.07 RON Save  18 %
426.32 RON Maandelijks
  • 8 dedicated CPUs AMD Ryzen 9 7950X met 4,5 GHz of vergelijkbaar, op Zen 4-architectuur. vCPU
  • 32 GB DDR5GEHEUGEN
  • 400 GB NVMeOPSLAG
  • Ongelimiteerde bandbreedte
  • IPv4 & IPv6 inbegrepen IPv6-ondersteuning is momenteel niet beschikbaar in Frankrijk, Finland of Nederland.
  • Auto back-up inbegrepen

*VAT excluded.

INCLUDED WITH EVERY PLAN

✓ No setup fees ✓1 Gbps network
✓ Full root access on KVM ✓ Free server monitoring ✓ Firewall management ✓24/7 support

Date format in SQL Server: CONVERT styles

Formatting is where most of the search traffic for date functions ends up, and the answer is CONVERT with a style number. The style is the third argument. There are around fifty of them and six carry almost all the real work.

DECLARE @d datetime2(3) = '2026-08-26 14:32:11.123';

SELECT CONVERT(varchar(10), @d, 23)  AS s23,   -- 2026-08-26
       CONVERT(varchar(10), @d, 103) AS s103,  -- 26/08/2026
       CONVERT(varchar(8),  @d, 112) AS s112,  -- 20260826
       CONVERT(varchar(19), @d, 120) AS s120,  -- 2026-08-26 14:32:11
       CONVERT(varchar(23), @d, 121) AS s121,  -- 2026-08-26 14:32:11.123
       CONVERT(varchar(27), @d, 126) AS s126;  -- 2026-08-26T14:32:11.123

Style 23 is a plain ISO date, yyyy-mm-dd. Style 103 is the British and French dd/mm/yyyy. Style 112 is the unseparated ISO form, yyyymmdd. Styles 120 and 121 are ODBC canonical, without and with milliseconds. Style 126 is ISO 8601, with the T separator and no spaces. When you convert a datetime in SQL Server without naming a style at all, 121 is the default for time, date, datetime2 and datetimeoffset, which is why those types come out carrying a fractional part you did not ask for. Most questions about datetime format in SQL Server end there. The rest of the fifty styles exist for reports that have to match a printed form from 1994.

Undersize the target and you get silent amputation, with no error raised. CONVERT(varchar(8), @d, 120) hands back 2026-08-. Size the varchar to the style.

The determinism rule matters if you ever want to index the result. Microsoft's CAST and CONVERT reference lists the nondeterministic styles as all styles below 100 with the exception of 20 and 21, plus 106, 107, 109, 113 and 130. That puts 23 in the nondeterministic column when one side is datetime or smalldatetime and leaves 103, 112 and 126 out of it, so a persisted computed column built on style 23 over a datetime column won't fly. Style 112 will.

There is more on the conversion side of this under CAST and CONVERT in SQL Server, including TRY_CONVERT and the precedence rules that decide which operand gets converted when you compare two different types. Dates are the worst offender there, since an implicit conversion on a date column can also turn an index seek into a scan without telling you.

FORMAT and locale-aware output

Zero padding, currency symbols and locale-aware month names all come out of FORMAT, which is why it turns up in the SQL Server string functions writeup as well. It arrived in SQL Server 2012 and takes .NET format strings plus an optional culture.

SELECT FORMAT(order_date, 'yyyy-MM-dd')          AS iso,
       FORMAT(order_date, 'dd MMM yyyy', 'en-GB') AS uk_long,
       FORMAT(order_date, 'dddd d MMMM yyyy', 'de-DE') AS german,
       FORMAT(total_amount, 'C', 'en-GB')         AS money_text
FROM dbo.orders;

Capital MM is months and lowercase mm is minutes, and mixing them up produces a string that looks almost right, which is worse than one that looks wrong. Microsoft's own guidance on the function is narrow: "Use the FORMAT function for locale-aware formatting of date/time and number values as strings. For general data type conversions, use CAST or CONVERT."

You'll read everywhere that FORMAT is dramatically slower than CONVERT. That claim comes from community benchmarking and not from Microsoft documentation, so treat it as a strong hint and not a specification. What is documented: FORMAT depends on the CLR, can't be remoted, and is nondeterministic. Those three facts keep it out of my hot paths and out of anything that needs an index. In a report query that runs twice a day I use it without thinking about it.

Parsing date strings: yyyy-mm-dd and yyyymmdd

Going the other way, from a string into a date, is where language settings do the most damage. The literal '03/04/2026' is 4 March under us_english and 3 April under British English, and both parse without complaint.

SET DATEFORMAT mdy;
SELECT CAST('03/04/2026' AS date) AS us_reading;  -- 2026-03-04

SET DATEFORMAT dmy;
SELECT CAST('03/04/2026' AS date) AS uk_reading;  -- 2026-04-03

Two literal forms are safe. yyyymmdd with no separators is immune to SET DATEFORMAT and SET LANGUAGE for every date type including the old datetime, which makes it the one I write in scripts that travel. yyyy-mm-dd is unambiguous for date, datetime2 and datetimeoffset, but for a plain datetime column that dashed form is still read through DATEFORMAT, so a session set to dmy chokes on '2026-08-26' as a datetime. Add the time with a T ('2026-08-26T14:32:11') and it's ISO again and safe everywhere.

If you're importing text of unknown quality, TRY_CONVERT gives you NULL for the rows that fail and lets the batch finish, and you can then look at what survived.

SELECT raw_value,
       TRY_CONVERT(date, raw_value, 112) AS parsed
FROM dbo.staging_dates
WHERE TRY_CONVERT(date, raw_value, 112) IS NULL;

AT TIME ZONE and sys.time_zone_info

AT TIME ZONE came in SQL Server 2016, always returns datetimeoffset, and reads its zone names from sys.time_zone_info.

SELECT name, current_utc_offset, is_currently_dst
FROM sys.time_zone_info
WHERE name IN ('UTC', 'GMT Standard Time', 'Central European Standard Time');

Applied to a naive datetime2 it assumes the value is already in that zone and attaches the offset. So converting a stored UTC timestamp to local time takes two steps: label it, then move it.

SELECT customer_id,
       created_utc,
       created_utc AT TIME ZONE 'UTC'
                   AT TIME ZONE 'Central European Standard Time' AS local_time
FROM dbo.customers;

The first AT TIME ZONE says "this value is UTC", the second shifts it, and daylight saving is handled from the zone's rules rather than a fixed offset. Skip the first step and SQL Server treats your UTC value as Warsaw local time and you're an hour or two out for half the year. On Linux the zone names still come from sys.time_zone_info rather than the /usr/share/zoneinfo identifiers you might expect, so query the view. Guessing at Europe/Warsaw will not get you there.

Filtering a date range with a half-open interval

BETWEEN is inclusive at both ends, and against a column with a time component that inclusivity is a trap. Microsoft spells it out on the BETWEEN page: when the time part is unspecified it defaults to midnight, so a row timestamped later that day isn't returned.

-- Loses almost all of 31 August
SELECT COUNT(*) FROM dbo.orders
WHERE order_date BETWEEN '20260801' AND '20260831';

-- Correct: half-open, and the column stays bare
SELECT COUNT(*) FROM dbo.orders
WHERE order_date >= '20260801'
  AND order_date <  '20260901';

The second form catches 23:59:59.999 on 31 August, works identically for date, datetime and datetime2 columns, and needs no change when someone later widens the column's precision. It also leaves order_date unwrapped, so an index on that column can still be seeked, which is the difference between a report that seeks straight to its rows and one that reads the whole table. Wrapping the column in CONVERT(date, order_date) = '20260826' is the most common way people throw that away.

Sargability is the thread to pull if the query above turns out slow on your data, and the WHERE clause in SQL Server is where I would pull it. Microsoft's date and time functions hub lists the SQL Server date functions this page skipped, a dozen or so, most of them wrappers or older spellings of something above. SWITCHOFFSET and TODATETIMEOFFSET are the two on that list worth opening.

Questions?

How do I get only the date from a datetime value?

CAST(order_date AS date) drops the time part and returns a real date value, and CAST(SYSDATETIME() AS date) gives today's date the same way. Keep the result as a date inside the query and format it only for display, because a date sorts and compares correctly while text such as '26/08/2026' sorts by its first characters. A comparison like CAST(order_date AS date) = '20260826' then matches every order from that day, whatever its time.

About the author

Oliver P. Redmond

I studied history and, for a while, expected to end up in archives (or academic publishing). Instead I took a copy-editing job with a small specialist publisher, learnt rather more about XML than the job description had implied and gradually found myself editing software manuals. Naturally, technical writing followed from there.

What I like about infrastructure is the accumulated quality. A modern service may have an elegant interface sitting on top of conventions and protocols that are sometimes older than the person using it. And so, understanding a system often means following those layers backwards. I write mostly about Linux systems, virtual machines and storage. I live in Bristol and still use a fountain pen for notes.

Deploy your next project in seconds

Bring your winning ideas online faster, with modern hardware and unmetered bandwidth. Join a European cloud trusted by thousands of developers and businesses worldwide.