Back to blog

July 27, 2026

·

OpenEphemeris Team

A Timestamp Is Not a Moment: Timezone and Location Handling in Astrological Software

Why a datetime without a zone is not a moment, which parts of a chart a few hours of error destroys, which parts hide it, and the order you have to resolve place, zone, and instant in.

engineeringtimezonedatetimeapiastrologydata-quality

Here is a string: 1987-07-15T09:01:00.

It looks like a moment. It parses without complaint in every language you can name. It has a year, a month, a day, and a clock reading down to the second. And it does not identify a point in time. Somewhere on Earth it is 9:01 AM right now, and somewhere else it is 9:01 AM twenty-six hours from now, and nothing in that string tells you which 9:01 was meant.

A clock time without a zone is a moment-shaped hole. That's easy to nod along to and hard to take seriously, because the failure that follows has a nasty property: the output still looks right.

Five hours, and what it does to a chart

Most bugs announce themselves. This one doesn't, because a chart isn't one calculation. It's a couple of dozen calculations stacked on each other, and they react to a time error at completely different rates.

Say a birth time is off by five hours — what you get by reading a US Central time as UTC.

What barely moves. The Sun travels about one degree per day, so five hours shifts it by about 0.2°. Mercury, Venus and Mars move slowly enough at that scale to stay put. Jupiter through Pluto might as well be nailed down — a five-hour error moves Pluto by an amount you would need a table to detect. Every one of these bodies keeps its sign. All of them. So a developer eyeballing the JSON sees Sun in Cancer, Venus in Gemini, Saturn in Sagittarius, and concludes the chart is fine.

What is destroyed. The Ascendant advances through the entire zodiac once per day — roughly 15° per hour, or one degree every four minutes. Five hours moves it about 75°, which is between two and three whole signs. The Midheaven moves with it. And because the house cusps are derived from the angles, every house placement in the chart changes. Every planet is still in the right sign and now in the wrong house. A reading built on that is not slightly off; it is about a different life.

The Moon sits between the two extremes at roughly 0.5° per hour, so five hours moves it about 2.75°. That is usually not enough to change its sign, which is why it also passes inspection — but it is more than enough to matter downstream.

What quietly inherits the error. This is the part people miss, because these are second-order calculations that amplify a small input error into a large output one:

  • Vimshottari dasha. The starting balance is a function of exactly how far the Moon has travelled through its nakshatra. A nakshatra is 13°20′ wide and the Moon crosses one in about a day, so a few degrees of Moon error is a meaningful fraction of the nakshatra — and that fraction is scaled onto a period that can run sixteen or twenty years. Hours of input error become years of predicted timing.
  • Astrocartography. Every line on the map is an angle line. If the angles moved, the map moved with them — the same 15°-per-hour rotation expressed as roughly 15° of longitude per hour. Five hours does not nudge the lines; it relocates them across an ocean.
  • BaZi hour pillar. The hour branch is a two-hour bin. Any error large enough to cross a bin boundary changes the pillar outright — and an error near midnight changes the day pillar too, which cascades into everything.
  • Human Design. The Personality side is the birth moment, so gate positions can slip a line; the Design side is calculated at a fixed solar arc before birth and carries the same error forward. The type and authority often survive, which makes them useless as a canary. Profile and the specific gate lines do not.
  • Anything with a threshold. Void-of-course Moon, exact aspect timing, ingress moments, retrograde stations, and eclipse contact times are all boundary questions. Boundary questions are the ones a small error flips.

The pattern: a zone error wrecks anything rotational and anything sitting on a threshold, and leaves anything slow and positional looking untouched. Slow and positional is exactly what a person checks first.

Offsets are not zones

The second trap is treating -05:00 as if it were a location's timezone. It is not. An offset describes one instant; a timezone is a ruleset that maps instants to offsets, and the rules change.

Chicago is -06:00 in January and -05:00 in July. If you store utc_offset: -6 for someone born in July, you have stored the wrong number for their birth. Worse, the rules also change across years: US daylight-saving dates moved in 2007, most of the country was a patchwork of local practice before 1966, and before standard time it was mean solar time keyed to the meridian you were standing on. Getting a 1940s birth right means applying the rules that were in force then, not the ones in force now.

Then there is the assumption that offsets are whole hours. India is +05:30. Nepal is +05:45. Chatham Island is +12:45. If your zone field is an integer, three of those are unrepresentable and you will silently round somebody's chart.

So: store and send the IANA zone name (America/Chicago), and let a tz-database-backed library resolve the offset for the date in question. Keep the resolved offset alongside it if you like — for display, for audit, for reproducing what you computed last year — but never as the primary key of what zone something was in.

Sequencing: you cannot resolve these in any order you like

There is a dependency chain here, and most timezone bugs are really sequencing bugs — a step run before the thing it depends on existed.

  1. Place → coordinates. Geocode first. Nothing downstream works without a latitude and longitude, and this step is also where the ambiguity lives: there are dozens of Springfields, and the wrong one is a different zone as well as a different horizon.
  2. Coordinates + local date → zone offset. Note that this step needs both. You cannot resolve an offset from a location alone, because DST depends on the date; and you cannot resolve it from a date alone, because rules are geographic. This is the step people short-circuit by resolving the zone with today's rules instead of the birth date's.
  3. Local wall-clock time + offset → UTC instant. Only now do you have a moment. Everything before this point is a description of a clock reading; everything after is a point on the timeline.
  4. UTC instant → Julian Day → ephemeris query. The astronomy is downstream of all of it and cannot detect an error upstream. Given a valid Julian Day, the ephemeris will return a mathematically perfect answer to the wrong question.
  5. Coordinates → angles and houses. Note that the coordinates are needed again, here, independently of the zone. This is why dropping location after the geocode step is its own bug: you can get the instant exactly right and still compute the houses for the wrong horizon.

Step 2 has a small circularity in it that causes real bugs. To convert local time to UTC you need the offset, and to find the offset you need to know the local date — including whether that local time falls in the one-hour gap where clocks spring forward (it may not exist) or the hour that repeats when they fall back (it may be ambiguous between two instants). Decide explicitly what you do in those two cases rather than inheriting whatever your date library happens to do.

Pack the tuple on every call

Once you've resolved a birth moment, it's tempting to boil it down to one UTC timestamp and pass that around. Don't throw the rest away.

Carry the whole tuple — instant, zone, coordinates — through every hop of your system: the queue message, the cache key, the stored record, the request to the next service. Three reasons:

You can explain it later. A UTC instant tells you when, not how you decided. Six months on, when someone says their chart is wrong, "we used America/Chicago, which resolved to -05:00" is an answer. A bare timestamp is a shrug.

Recomputation stays correct. Any downstream step that needs to re-derive something local — a transit for "the morning of their birthday", a solar return cast at the relocated place, a daily chart — needs the zone, not just the instant. If it only has UTC, it will invent a zone, and you are back where you started.

Your cache stays honest. If your cache key is the UTC instant plus coordinates but your rendering uses a zone you did not include in the key, two different requests collide onto one entry.

And echo the resolution back in the response. A response that includes the UTC instant actually used, the zone it came from, and how that zone was established turns an entire category of support ticket into a five-second check. It also makes the failure mode visible to the consumer, which is the whole game.

Reject; do not default

That leaves one decision. A naive datetime arrives — do you assume a zone, or refuse it?

Assuming is tempting, because it never breaks anyone's integration. But look at what it does: you pick a zone nobody stated, compute a chart for a moment nobody meant, and hand it back with exactly the confidence of a right answer. The caller gets no signal — well-formed JSON, planets in plausible signs, no error. A guess is only safe when a wrong guess is visible, and this one isn't.

Refusing costs one retry and a clear error message. So: return a 400, name the field, and — this matters — state the remedies that field actually supports. If the value can take an offset suffix, say so. If there is a companion zone field, name it. If there isn't one, don't mention it; an error that advertises an impossible fix is worse than a terse one.

The same discipline pays off on the input side of your own UI. If you collect a birth time, collect a birth place in the same breath and resolve the zone from it, rather than asking a user to know their historical UTC offset. They don't know it. Nobody knows it. That's what the tz database is for.

A short test list

If you want to know whether your datetime handling holds up, these are the cases that find the bugs:

  • A summer birth in a DST-observing zone, and a winter one in the same zone — the offsets must differ.
  • A birth in the hour that DST skips, and one in the hour it repeats.
  • A pre-1970 birth, and one before standard time reached that region.
  • A half-hour or quarter-hour zone.
  • A southern-hemisphere birth, where DST runs in the opposite half of the year.
  • A birth within a few minutes of local midnight, where the date is zone-dependent.
  • And one golden chart with independently verified angles, asserted to the decimal — because the Ascendant is the value that moves fastest, it is the one that catches everything upstream of it.

Assert on the angles, not on the planets. The planets will tell you what you want to hear.