Summary#
A DateTimeOffset is an instant and the offset it was recorded at. That second half is the whole reason to
reach for it: a DateTime here is always a UTC instant, so an invoice stamped 09:00+05:30
and one stamped 03:30Z are the same DateTime — and only the first tells you it was morning in India.
DateTimeOffset recordedAt = order.PlacedAt; // 2026-09-14T14:30:00+05:30
recordedAt.Hour // 14 — the clock on the wall where it happened
recordedAt.Offset // +05:30, as a TimeSpan
recordedAt.UtcDateTime // 09:00 — the same moment, as an ordinary DateTimeSignature#
DateTimeOffset At; // on an entity or a class
DateTimeOffset Stamp(DateTimeOffset at) { … }
DateTimeOffset.UtcNow // now, at offset zero
DateTimeOffset.FromUnixTimeSeconds(s) // an instant sent as seconds since 1970, at offset zero
DateTimeOffset.Parse("2026-09-27T12:30:00+02:00") // ISO text; text with no offset is read as UTC
new DateTimeOffset(someDateTime) // the same instant, at offset zero
at.ToUniversalTime() // the same instant, at offset zero (a DateTime answers itself)
at.ToUnixTimeMilliseconds() // …and back, as milliseconds
at.ToOffset(TimeSpan.FromHours(1)) // the same instant, read at another offsetDescription#
The parts are the LOCAL reading#
.Year, .Month, .Day, .Hour, .Minute, .Second and .DayOfWeek all answer about the wall clock the value
carries — the time where it was recorded. That is the information the offset exists to preserve, so reading them as
UTC would quietly be about a different moment of the day.
Two members have no counterpart on a DateTime:
.Offset | the offset itself, as a TimeSpan (TimeSpan (durations)) |
.UtcDateTime | the same instant as a plain DateTime, offset discarded |
.DateTime | the local reading as a plain DateTime, offset discarded |
Comparison is by the instant#
<, > and == compare the moment, never the local reading. Two values recorded thirteen and a half hours
apart on the clock can be equal, because they name the same instant:
// 14:30 in India and 01:00 in Los Angeles are both 09:00Z.
indiaStamp == losAngelesStamp // trueSorting a column of them orders by instant too, which is almost always what a timeline wants.
Converting#
A DateTime widens to a DateTimeOffset on its own, because nothing is lost — it lands at offset zero and
says so. There is no conversion back, deliberately: dropping an offset is a choice about which instant you meant,
so you name it.
DateTimeOffset stamped = someDateTime; // ✓ implicit — lands at +00:00
DateTime plain = stamped.UtcDateTime; // ✓ the instant
DateTime local = stamped.DateTime; // ✓ the wall clock it was recorded atnew DateTimeOffset(dateTime) says the widening out loud, and ToUniversalTime() moves a DateTimeOffset to offset
zero (on a DateTime, which is already a UTC instant here, it answers the value itself). DateTimeOffset.Parse reads
ISO-8601 text and keeps the offset the text carries; text with NO offset is read as UTC — never as the server's local
zone, which would make the answer depend on where it runs.
Arithmetic, and mixing with a DateTime#
DateTime and DateTimeOffset are one family, as in C#. Wherever one of each meets an operator, the DateTime
widens (to offset zero) and the DateTimeOffset operator applies — so there is nothing to convert by hand:
| written | answers |
|---|---|
dto - dto, dateTime - dto, dto - dateTime | a TimeSpan — the time between the two instants |
dto + span, dto - span | a DateTimeOffset, at the same offset |
dateTime < dto, <=, >, >= | ordered by the instant |
dateTime == dto, != | equal when they name the same instant, whatever the offsets |
DateTime due = order.DueAt; // a UTC instant
DateTimeOffset seen = order.SeenAt; // 14:30 +05:30
TimeSpan early = due - seen; // ✓ widened, then subtracted
DateTimeOffset reminder = seen + TimeSpan.FromHours(1); // 15:30 +05:30 — the offset is kept
bool late = seen > due; // ✓ by the instantThe same rules hold in a server function, in a browser action, and in a query that runs in the database —
Reading.Where(r => r.At > cutoff) with cutoff a DateTime, or r.At - r.Logged > TimeSpan.FromHours(1), is
answered there exactly as it would be in memory.
Choosing between them meets at DateTimeOffset too. flag ? dateTime : dto, maybeDateTime ?? dto, and a
switch whose arms give one of each are all a DateTimeOffset, with the DateTime side converted (to offset zero, a
DateTime? that is null staying null) — as C#'s best common type is. The same conversion happens wherever a
DateTime is written into a DateTimeOffset slot: a local, an assignment, an argument, a return, a member. In a
query's select list the result carries the offset of whichever side the row took.
What C# does not have, Osy# does not either: there is no TimeSpan + DateTimeOffset (the instant goes on the left),
and two instants cannot be added.
Unix time, and reading at another offset#
An API that sends an instant as a count of seconds or milliseconds since 1970 is read the way C# reads it, with
DateTimeOffset.FromUnixTimeSeconds and DateTimeOffset.FromUnixTimeMilliseconds, and written back with
.ToUnixTimeSeconds() and .ToUnixTimeMilliseconds(). ToOffset reads the same instant at another offset, and
adding to a DateTimeOffset (AddDays, AddHours, AddTicks, …) keeps its offset:
var shipped = DateTimeOffset.FromUnixTimeSeconds(1616173619); // 2021-03-19 17:06:59 +00:00
var inParis = shipped.ToOffset(TimeSpan.FromHours(1)); // 2021-03-19 18:06:59 +01:00
var dueBack = inParis.AddDays(14); // still +01:00
long sentAt = dueBack.ToUnixTimeMilliseconds(); // milliseconds since 1970
long seconds = dueBack.ToUnixTimeSeconds(); // …or seconds, the coarser wire form⚠ Both directions are long, and only a DateTimeOffset has them. .ToUnixTimeSeconds() and
.ToUnixTimeMilliseconds() are declared on this type alone, because the conversion needs the offset to know which
instant it is naming — a bare DateTime does not carry one. Convert first if that is what you hold.
The epoch itself is a value you can name rather than a literal you retype: DateTimeOffset.UnixEpoch is
1970-01-01 at offset zero, and DateTime.UnixEpoch is the same moment as a plain instant, for arithmetic that
stays in DateTime:
var age = DateTimeOffset.UtcNow - DateTimeOffset.UnixEpoch; // a TimeSpan since 1970
var asDate = DateTime.UnixEpoch.AddSeconds(1616173619); // the same read, in DateTime termsIt is the same value on both sides#
A DateTimeOffset behaves identically in a server function and in a browser action — same instant, same offset,
same comparison rule. On the wire it travels as ISO-8601 text with its offset (2026-09-14T14:30:00+05:30),
which is the only textual form that keeps what the type is for.
Examples#
// A reading whose local time matters — the same instant, recorded in three places.
entity Reading {
[Required] string Sensor;
DateTimeOffset At;
}
string WhenLocally(Reading r) {
return Convert.ToString(r.At.Hour) + ":" + Convert.ToString(r.At.Minute);
}A DateTime and a DateTimeOffset in one expression — subtracted, moved and compared, in memory and in a query:
entity Delivery {
[Required] string Parcel;
DateTime Promised; // a UTC instant
DateTimeOffset Handed; // when, and at what offset, it was handed over
}
TimeSpan Lateness(Delivery d) {
return d.Handed - d.Promised; // DateTimeOffset − DateTime → TimeSpan
}
DateTimeOffset FollowUp(Delivery d) {
return d.Handed + TimeSpan.FromDays(2); // keeps the offset it was handed over at
}
List<Delivery> LateOnes() {
return Delivery.Where(d => d.Handed > d.Promised).ToList(); // compared in the database, by the instant
}
DateTimeOffset Settled(Delivery d, bool handed) {
return handed ? d.Handed : d.Promised; // one of each → a DateTimeOffset; Promised lands at +00:00
}See also#
- DateTime — a UTC instant, and the right choice when when is all you need
- TimeSpan (durations) — what
.Offsetanswers - DateOnly and TimeOnly — the wider date/time surface