~ / topics / Languages
$man lang-dotnet

.NET (C#, F#, VB.NET)

In brief: .NET's own date types are not affected by 2038. They count time in 100-nanosecond "ticks" in a 64-bit number and run to the end of year 9999. The risks are where .NET meets other systems: Unix timestamps squeezed into a 32-bit int, native libraries on 32-bit Linux, and older SQL Server and COM interfaces. Since .NET 9, the official 32-bit Arm builds for Linux are built for 64-bit time.

Risk level Low for the runtime. Medium for interop code and older Arm32 deployments
Status DateTime/DateTimeOffset are safe to 9999. The Arm32 Linux runtime has been built against a Y2038-compatible glibc since .NET 9. Application-level int conversions remain
Last reviewed 2026-10-21

2038 status at a glance

Not affected or different deadline (1), and why

Overview

System.DateTime stores a 64-bit Ticks value: "the number of 100-nanosecond intervals that have elapsed since 12:00:00 midnight, January 1, 0001" [1]. DateTime.MaxValue is 23:59:59.9999999 on 31 December 9999, which is 3,155,378,975,999,999,999 ticks [2]. DateTimeOffset adds a UTC offset to the same tick count. Managed code that stays in these types has no 2038 boundary. It does have a year-10000 boundary, which is a different problem.

The Unix-time helpers are 64-bit as well:

Technical details

Pitfall 1: storing Unix seconds in int

// BUG: explicit cast truncates silently in the default unchecked context
int expires = (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds() + ttl;

// BUG: old hand-rolled helpers often look like this
static DateTime FromUnix(int secs) => new DateTime(1970,1,1,0,0,0,DateTimeKind.Utc).AddSeconds(secs);

// FIX: long end to end
long expires = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + ttl;
static DateTimeOffset FromUnix(long secs) => DateTimeOffset.FromUnixTimeSeconds(secs);

// FIX at a boundary you can't change: fail loudly
int wire = checked((int)secs);   // OverflowException instead of wrapping

C# compiles in an unchecked context by default, so (int)someLong keeps the low 32 bits without any error. Turn on <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> in test builds, or use checked(...) at serialisation boundaries.

Pitfall 2: P/Invoke and time_t

There is no time_t type in .NET. Interop signatures are hand-written, and many older ones declare the native time value as int:

// BUG: assumes 32-bit time_t; wrong once the native side is 64-bit
[DllImport("libc")] static extern int time(IntPtr t);

// FIX: on 64-bit Linux/macOS time_t is 64-bit (long in C#). On 32-bit
// Linux it depends on how the native library was built, so prefer
// managed APIs (DateTimeOffset.UtcNow) over calling libc directly.
[DllImport("libc")] static extern long time(IntPtr t);   // 64-bit targets

Structs marshalled with [StructLayout(LayoutKind.Sequential)] that mirror struct timespec or struct stat also change layout when the native time_t grows. See Lang-C-and-CPP.

The runtime itself on 32-bit Arm Linux

The runtime's native shim (System.Native, the crypto shim and others) is C code, so it inherits the C library's time_t. On Ubuntu 24.04 armhf, which moved to 64-bit time_t, HTTPS requests from older .NET builds failed with a NotTimeValid certificate error (dotnet/runtime #101444). PR #102410, merged 2024-05-22, rebuilt the linux-arm runtime against a glibc with _TIME_BITS=64. It also added run-time detection of whether the system OpenSSL uses 32-bit or 64-bit time_t, so that it doesn't pass values that don't fit [5]. The .NET 9 supported-OS notes now say: "Microsoft-provided portable Arm32 glibc builds are supported on distro versions with a Y2038 compatible glibc, for example Debian 12, Ubuntu 22.04, and higher versions" [6].

A related dotnet/docs pull request (#40669) drafted breaking-change notices saying that .NET 6, 8 and 9 are "not supported on Y2038-compatible Arm32 distros". That PR shows as closed, not merged, so treat its exact version wording as (unverified). Check the current supported-OS matrix for your .NET release before you deploy on armhf [7].

Windows CRT underneath

Native code that .NET calls on Windows uses the UCRT, where time_t is 64-bit by default and _USE_32BIT_TIME_T is discouraged. Microsoft warns that "your application may fail after January 18, 2038" if you define it [8]. Old native DLLs built with that macro, or with Visual C++ 2003 or earlier, may still return 32-bit times.

COM / OLE Automation DATE

The OLE Automation DATE (VT_DATE) is an 8-byte double. Its integer part counts days from 30 December 1899 and its fraction is the time of day [9]. DateTime.ToOADate() covers 1 January 0100 to 31 December 9999 and throws OverflowException outside that range [10]. It has no 2038 limit, but it has other hazards:

Excel, VBA, Access and classic ASP all use this representation. The risk there is a 32-bit Unix timestamp column that someone has bolted on beside it, not DATE itself.

Win32 FILETIME

FILETIME is a 64-bit count of 100 ns intervals since 1601-01-01, and it's safe. Interop code that splits it into dwLowDateTime/dwHighDateTime and then converts to Unix seconds in an int brings the 2038 bug back. See OS-Windows.

SQL Server from .NET

A common T-SQL idiom for converting epoch seconds is DATEADD(second, @unix, '1970-01-01'). On SQL Server 2017–2022 the number argument is an int. Values beyond 2,147,483,647 fail with Msg 8115 Arithmetic overflow error converting expression to data type int. SQL Server 2025 (ver17), Azure SQL and Fabric document number as bigint [11]. On older versions, split the value: add days first, then the remaining seconds. See Databases.

Affected versions / products

Item Representation Range / 2038 exposure
DateTime, DateTimeOffset Int64 ticks (100 ns) since 0001-01-01 to 9999-12-31; none
ToUnixTimeSeconds / FromUnixTimeSeconds Int64 none (.NET Framework 4.6+, .NET Core 1.0+)
OLE DATE / ToOADate double days since 1899-12-30 0100–9999; none
App code (int) casts, int columns Int32 2038
.NET on linux-arm (32-bit glibc) native time_t Built Y2038-compatible since .NET 9 [5][6]. Older builds on t64 distros (see above)
SQL Server 2017–2022 DATEADD(second, …) int argument 2038 for epoch conversion

Risk & compliance notes

Detection & mitigation

  1. Search for (int) next to ToUnixTimeSeconds, TotalSeconds and Ticks /, and for AddSeconds( with int parameters.
  2. Turn on checked arithmetic in CI test runs (CheckForOverflowUnderflow) so that wraps throw.
  3. Inject TimeProvider (.NET 8+) and test with 2038-01-19T03:14:08Z and later.
  4. Move linux-arm workloads to .NET 9 or later on a Y2038-compatible distro, or to arm64.
  5. Follow Testing-and-Detection and Remediation-Guide.

See also

References

  1. DateTime.Ticks Property — Microsoft Learn — 100 ns ticks since 0001-01-01; Int64.
  2. DateTime.MaxValue Field — Microsoft Learn — 9999-12-31 23:59:59.9999999; 3,155,378,975,999,999,999 ticks.
  3. DateTimeOffset.ToUnixTimeSeconds Method — Microsoft Learn — returns Int64; available from .NET Framework 4.6 / .NET Core 1.0.
  4. DateTimeOffset.FromUnixTimeSeconds(Int64) — Microsoft Learn — valid range and ArgumentOutOfRangeException.
  5. Build against Y2038-compatible glibc for linux arm32 — dotnet/runtime PR #102410, merged 2024-05-22 — fixes #101444 (NotTimeValid on Ubuntu 24.04 Arm32); OpenSSL time_t detection.
  6. .NET 9 supported OS — dotnet/core release notes — Arm32 builds require Y2038-compatible glibc (Debian 12, Ubuntu 22.04+).
  7. Add Y2038 related breaking change notices — dotnet/docs PR #40669 — draft notices for .NET 6/8/9 on Y2038-compatible Arm32 distros; PR closed.
  8. time, _time32, _time64 — Microsoft Learn — 64-bit time_t default; _USE_32BIT_TIME_T warning.
  9. DATE Type — Microsoft Learn (ATL/MFC) — 8-byte float, days since 1899-12-30, discontinuity before 0.
  10. DateTime.ToOADate Method — Microsoft Learn — OLE date range 0100–9999; OverflowException.
  11. DATEADD (Transact-SQL) — Microsoft Learn — number is int on SQL Server 2017–2022, bigint on SQL Server 2025/Azure; Msg 8115.