ESP32: sync time over NTP and handle timezones + DST correctly
Get real wall-clock time on the ESP32 over NTP: settimeofday, the TZ string that handles DST for you, and a self-hosted angle pointing at your own router or a Pi.
Half the ESP32 projects that log anything eventually need to know what
time it actually is. Not “milliseconds since boot”, which is what
millis() gives you, but wall-clock time that survives a reboot, agrees
with the rest of the house, and knows that March 8th is not the same as
March 8th in November. NTP (the Network Time Protocol, a protocol older
than most of its users) gets you that in about ten lines, and the
ESP32’s SDK has the timezone and daylight-saving machinery already
built in. The part nobody shows you is the one line of configuration
that makes DST work, so most tutorials skip it and your timestamps go
wrong twice a year.
The trap is the epoch. time(nullptr) after boot returns something
like 315,583,200 seconds (1979, in my case, every single time) because
the ESP32 has no battery-backed clock: the year is whatever the SDK’s
default is until the first NTP sync completes. Sketches that print the
time immediately in setup() print 1980-something, conclude “NTP
broken”, and start hardcoding offsets. The fix is not code, it is
patience plus one check: wait for the sync to actually finish before
you trust the clock.
What you need
Needed
| Item | Qty | Purpose | Est. cost |
|---|---|---|---|
| ESP32 dev board (ESP32-DevKitC or clone) | 1 | the brain | $8-$15 |
| Wi-Fi network with internet access | 1 | reaches an NTP server | $0 |
| That is the whole list | no hardware to buy |
This is a software tutorial. Everything happens over the network, which is exactly why NTP is the cheap upgrade every logging project should get before shipping.
Nice to have
- A Raspberry Pi already on your network for the self-hosted NTP section (any always-on Linux box works, e.g. the Pi running Mosquitto from this site’s MQTT broker tutorial)
- Multimeter and breadboard only if you are wiring this into a larger sensor build anyway
Wiring
None. This one is pure software. If your ESP32 is part of a sensor build, the only hardware note is this: NTP needs UDP port 123 outbound, and some locked-down guest Wi-Fi networks block it. Your home network almost certainly does not.
Install
Nothing to install. configTime(), getLocalTime(), and the timezone
database all ship with the ESP32 Arduino core. That is part of why this
belongs in every project: the cost is one block in setup().
How NTP actually lands on the ESP32
Three pieces, in order:
- The sync:
configTime()tells the SDK which NTP servers to ask. The ESP32 sends a UDP packet, the server replies with a 64-bit timestamp good to tens of milliseconds, andsettimeofday()runs behind the scenes. Repeats hourly by default. - The timezone: a POSIX TZ string like
"MST7MDT,M3.2.0,M11.1.0". Read it as: base offset MST7, then the daylight rules MD, then when they start and end (second Sunday in March, first Sunday in November). The libc layer applies those rules forever, including the two hours a year nobody wants to debug at midnight. - The read:
getLocalTime()blocks until time is valid and hands you a filled-instruct tm. That blocking behavior is your “sync finished” check for free.
The DST rules are the whole ballgame and they live entirely inside that string. There is no “enable DST” checkbox; there is only the string. Get the string right and March forwards itself.
The code
#include <WiFi.h>
#include <time.h>
const char* WIFI_SSID = "your-network";
const char* WIFI_PASS = "your-password";
// Mountain Time: MST7 (UTC-7), DST in summer, second Sunday in March
// to first Sunday in November. Swap for your zone; see list below.
const char* TZ_RULE = "MST7MDT,M3.2.0,M11.1.0";
// Self-hosted first: most routers run an NTP server (OpenWrt and
// pfSense do by default). Point at your router, then public fallbacks.
const char* NTP_PRIMARY = "192.168.1.1"; // your router or Pi
const char* NTP_FALLBACK = "pool.ntp.org";
void setup() {
Serial.begin(115200);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.printf("\nWi-Fi up, IP %s\n", WiFi.localIP().toString().c_str());
configTime(0, 0, NTP_PRIMARY, NTP_FALLBACK); // offsets live in TZ now
setenv("TZ", TZ_RULE, 1);
tzset();
}
void loop() {
struct tm timeinfo;
if (!getLocalTime(&timeinfo, 5000)) { // blocks up to 5 s waiting for sync
Serial.println("clock not synced yet");
delay(2000);
return;
}
char buf[64];
strftime(buf, sizeof(buf), "%A %B %d %Y %H:%M:%S %Z", &timeinfo);
Serial.println(buf);
// millis() still has its job: measuring durations, not naming moments.
Serial.printf("(up %lu ms since boot)\n", (unsigned long)millis());
delay(10000);
}
The two configTime(0, 0, ...) zeros matter: pass the UTC offset as 0
and let the TZ string own all offsets. Setting both is the classic
double-offset bug (time lands off by exactly twice your zone, e.g. UTC
+14 in my case, a very confusing afternoon).
Common TZ strings, ready to paste:
| Zone | TZ string |
|---|---|
| US Mountain | MST7MDT,M3.2.0,M11.1.0 |
| US Eastern | EST5EDT,M3.2.0,M11.1.0 |
| US Pacific | PST8PDT,M3.2.0,M11.1.0 |
| Central Europe | CET-1CEST,M3.5.0,M10.5.0/3 |
| UK | GMT0BST,M3.5.0/1,M10.5.0 |
| UTC (no DST) | UTC0 |
The self-hosted angle
Every tutorial points at pool.ntp.org and stops. You can do better,
and it costs one line: point the primary at your own network first.
- Your router is probably already an NTP server. OpenWrt and pfSense run one by default; stock ISP routers often do too. Try its LAN IP as the primary and check the serial output: if timestamps appear, you are time-syncing from a box you own.
- A Raspberry Pi makes a better one if you want the project-shaped
version. Install chrony (
sudo apt install chrony), addlocal stratum 8to/etc/chrony/chrony.confso it can serve time even without internet, then point every ESP32 in the house at it. Now timestamps keep working during internet outages, which is exactly when you care about local logs. - A full stratum-2 setup (Pi with GPS or a dedicated NTP appliance) is real but out of scope. What matters is the topology: the ESP32 should ask your network first, the internet second. That ordering is the self-hosted angle, and it makes every device on your LAN a little less dependent on outside services.
The fallback chain in the sketch above does that: router first, pool.ntp.org second. If your network is down, there is nothing to sync from anyway; if only the internet is down, a chrony Pi keeps every device honest.
What you learned
- Wall-clock time on the ESP32 is three lines:
configTime()for servers,setenv("TZ", ...)for the zone rules,getLocalTime()to wait for and read the result. - DST is not a setting, it is part of the TZ string, and libc applies the transitions for you every March and November.
- Pointing NTP at your own router or a Pi first keeps timestamps flowing when the internet is not, and it is one line.
millis()measures durations; NTP names moments. A logging sketch wants both and they answer different questions.
When something breaks
- The year prints as 1970 or 1980: the sync never completed. Check Wi-Fi actually connected, and whether the network blocks UDP 123 (guest and school networks often do). Test with a phone hotspot.
- Time is off by exactly twice your UTC offset: you set a nonzero
offset in
configTime()and the TZ string also carries one. Keep the zeros inconfigTime()and let the TZ string own everything. - DST does not change in March: the TZ rule part after the comma is missing or malformed. The string must have all three parts: offset, DST name, transition dates. Compare against the table above.
getLocalTime()always times out on the first call right after Wi-Fi connects: the first NTP exchange takes a second or two. That is why the function blocks; give it its 5 seconds instead of retrying in a tight loop.- Timestamps drift minutes per day with no NTP route: the ESP32’s
internal oscillator is not a clock, it is a suggestion. That is the
design: resync hourly, which
configTime()does automatically. - After deep sleep the time is wrong again: RTC memory survives light sleep, not deep sleep. Re-sync on wake (the deep sleep tutorial on this site covers what does and does not survive).
What to build next
- The ESP32 email over SMTP tutorial needs correct time more than anything: TLS certificate validation quietly depends on the clock being roughly right.
- The NTP timestamped MQTT publishing pattern (the MQTT tutorial on this site) turns a stream of readings into something a database can actually plot.
- The InfluxDB timeseries tutorial will reject or misorder points with bad timestamps; run this tutorial first.
- The deep sleep tutorial pairs with this one for battery loggers that wake, sync, stamp, and sleep.
The book IoT with ESP32 bundles the connectivity tutorials including this one.