esp32 intermediate 30 min

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.

Code available for: ESP32 ArduinoArduino C
Published Sep 22, 2026

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

ItemQtyPurposeEst. cost
ESP32 dev board (ESP32-DevKitC or clone)1the brain$8-$15
Wi-Fi network with internet access1reaches an NTP server$0
That is the whole listno 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:

  1. 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, and settimeofday() runs behind the scenes. Repeats hourly by default.
  2. 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.
  3. The read: getLocalTime() blocks until time is valid and hands you a filled-in struct 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:

ZoneTZ string
US MountainMST7MDT,M3.2.0,M11.1.0
US EasternEST5EDT,M3.2.0,M11.1.0
US PacificPST8PDT,M3.2.0,M11.1.0
Central EuropeCET-1CEST,M3.5.0,M10.5.0/3
UKGMT0BST,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), add local stratum 8 to /etc/chrony/chrony.conf so 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 in configTime() 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.