I had a batch importer stamping DateTime.Now onto every row it processed. A few hundred thousand rows, one timestamp each, nothing I'd ever think twice about. Then I put a profiler on the thing because it was slower than it had any right to be, and there it was near the top: reading the clock. Not the database, not the parsing. The clock.
So I did the thing I always end up doing. I counted.
Now is UtcNow wearing a coat
DateTime.Now doesn't read a "local clock." There's no such thing. It reads UTC, exactly like DateTime.UtcNow does, and then converts that instant into local time using TimeZoneInfo.Local. That conversion is the whole difference between the two calls. One reads the clock. The other reads the clock and then asks the time zone database what your offset is right now, DST and all.
The offset lookup is the part that costs. And here's the bit that surprised me: it costs even when the answer is zero.
Putting a clock on the clock
I read each one 20 million times, summed the ticks so nothing got optimized away, best of five rounds. Same loop, three bodies.
static long ReadUtcNow() => DateTime.UtcNow.Ticks; static long ReadNow() => DateTime.Now.Ticks; static long ReadStopwatch() => Stopwatch.GetTimestamp(); First run, on the container as it ships, which pins itself to UTC:
Local time zone: Etc/UTC 20,000,000 reads, best of 5 DateTime.UtcNow 661.5 ms 33.1 ns/call DateTime.Now 911.8 ms 45.6 ns/call Now / UtcNow 1.38x A UTC box has a zero offset. There's nothing to add. And Now is still almost 40% slower, because it does the lookup to find out the offset is zero. The work happens whether or not it changes the answer.
Then I pointed TZ at a real zone, the kind your laptop or an on-prem server actually runs, with daylight saving rules to evaluate:
Local time zone: America/New_York 20,000,000 reads, best of 5 DateTime.UtcNow 678.3 ms 33.9 ns/call DateTime.Now 1,357.2 ms 67.9 ns/call Now / UtcNow 2.00x Twice the cost. UtcNow sits around 33 ns no matter what I do; Now lands somewhere between 60 and 68 ns across runs. The time zone isn't part of reading the clock. It's a question Now asks afterward, every single time.
This is a container, not a lab, so I care about the ratio, not the exact nanoseconds. The ratio was steady run to run. Roughly double on a real zone, roughly a third extra on UTC.
The tool I actually wanted
Half the time I reach for DateTime.Now I'm not even recording a wall-clock time. I'm measuring how long something took. For that, both Now and UtcNow are the wrong tool, because they drag calendar math into a job that's really just "count ticks between here and there." Stopwatch does exactly that and nothing else:
Stopwatch.GetTimestamp 637.3 ms 31.9 ns/call Cheapest of the three, and it never touches a time zone or even cares what day it is. If you're timing a block of code, this is the one.
When I'd leave it alone
If you call DateTime.Now a handful of times per request, this whole post is noise. Thirty-odd extra nanoseconds disappears against literally anything else your code does. I'm not going to rewrite a login handler to shave that off, and neither should you. The importer only cared because it was reading the clock once per row, hundreds of thousands of times, in a loop that otherwise did very little. Hot path, high count. That's the only place it shows up.
And don't go blindly swapping Now for UtcNow in display code. If you're formatting a timestamp a human is going to read, they want their local time, and UtcNow will happily show them the wrong hour. That's a correctness bug, and no benchmark makes it worth shipping.
My own rule, for what it's worth: compute and store in UTC, read the clock with UtcNow, time things with Stopwatch, and convert to local exactly once, at the edge, right before a person sees it. Local time is a presentation concern. Treating it like a data concern is how you end up paying the time zone tax a few hundred thousand times to import a file nobody's even looking at yet.
Here's the whole thing if you want to run it yourself. New console project targeting net10.0, drop this in Program.cs, then dotnet run -c Release, and TZ="America/New_York" dotnet run -c Release for the wider gap:
using System.Diagnostics; Console.WriteLine($"Local time zone: {TimeZoneInfo.Local.Id}"); const int Iterations = 20_000_000; const int Rounds = 5; // Warm up both paths so the JIT and the time-zone cache are hot. _ = Measure(ReadUtcNow, 1_000_000); _ = Measure(ReadNow, 1_000_000); long utcTicks = long.MaxValue, nowTicks = long.MaxValue, swTicks = long.MaxValue; for (int r = 0; r < Rounds; r++) { utcTicks = Math.Min(utcTicks, Measure(ReadUtcNow, Iterations)); nowTicks = Math.Min(nowTicks, Measure(ReadNow, Iterations)); swTicks = Math.Min(swTicks, Measure(ReadStopwatch, Iterations)); } double utcMs = utcTicks * 1000.0 / Stopwatch.Frequency; double nowMs = nowTicks * 1000.0 / Stopwatch.Frequency; double swMs = swTicks * 1000.0 / Stopwatch.Frequency; Console.WriteLine($"{Iterations:N0} reads, best of {Rounds}"); Console.WriteLine($" DateTime.UtcNow {utcMs,8:N1} ms {utcMs * 1e6 / Iterations,5:N1} ns/call"); Console.WriteLine($" DateTime.Now {nowMs,8:N1} ms {nowMs * 1e6 / Iterations,5:N1} ns/call"); Console.WriteLine($" Now / UtcNow {nowMs / utcMs,8:N2}x"); Console.WriteLine($" Stopwatch.GetTimestamp {swMs,8:N1} ms {swMs * 1e6 / Iterations,5:N1} ns/call"); return; static long Measure(Func<long> body, int iterations) { long sink = 0; var sw = Stopwatch.StartNew(); for (int i = 0; i < iterations; i++) sink += body(); sw.Stop(); if (sink == -1) Console.WriteLine("unreachable"); // keep the loop alive return sw.ElapsedTicks; } static long ReadUtcNow() => DateTime.UtcNow.Ticks; static long ReadNow() => DateTime.Now.Ticks; static long ReadStopwatch() => Stopwatch.GetTimestamp(); Do you default to Now or UtcNow when you're not thinking about it? I'd bet most of us type whichever one our fingers learned first. Curious which camp you're in.
— still putting clocks on things nobody asked me to time


